Compare commits

..

989 Commits

Author SHA1 Message Date
renovate[bot] d17ab1a165 chore(deps): Update crowdin/github-action action to v3 (#1778)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 16:19:14 +08:00
renovate[bot] c290701292 chore(deps): Update non-major dependencies (#1779)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 16:19:01 +08:00
renovate[bot] 1a560647e3 chore(deps): Update module github.com/libdns/namesilo to v1.1.1 (#1777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 23:52:00 +08:00
耗子 9a81bc2713 fix: 修复翻译错误 2026-08-28 17:46:05 +08:00
耗子 f2ef9c5010 feat: MySQL表维护支持筛选数据库 2026-08-28 16:56:27 +08:00
renovate[bot] 4609322ee4 chore(deps): Update dependency naive-ui to v2.45.3 (#1775)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 01:01:36 +00:00
renovate[bot] 1e6a8d8075 chore(deps): Update module github.com/andybalholm/brotli to v1.2.3 (#1774)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 01:14:12 +08:00
github-actions[bot] 4fd49fd471 New Crowdin translations by GitHub Action (#1773)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-28 01:12:28 +08:00
renovate[bot] b7ef703811 chore(deps): Update non-major dependencies (#1772)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 01:12:09 +08:00
github-actions[bot] bf9324db47 chore(l10n): update pot files 2026-08-27 16:21:00 +00:00
耗子 4c263e290a fix: 优化nginx默认值 2026-08-27 22:52:29 +08:00
耗子 2d4a06508c fix: 优化样式 2026-08-27 22:40:27 +08:00
耗子 98c205ed01 fix: 优化样式 2026-08-27 22:38:50 +08:00
耗子 74a88433f8 fix: 优化样式 2026-08-27 22:33:31 +08:00
github-actions[bot] f09e65bf32 New Crowdin translations by GitHub Action (#1771)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-27 22:27:29 +08:00
github-actions[bot] 27936fbf19 chore(l10n): update pot files 2026-08-27 13:32:29 +00:00
耗子 d87d958f3e fix: 基本认证规则优先级与目录必填校验
nginx map 正则、Apache Location 块分别按路径长度排序,保证嵌套目录
与整站规则并存时更精确的规则生效;前端保存时校验目录级规则必须填写
目录,避免空路径被归一化为整站规则。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 21:29:06 +08:00
renovate[bot] f71e4f1e0a chore(deps): Update dependency vue to v3.5.42 2026-08-27 11:14:52 +00:00
github-actions[bot] d5af4524d2 New Crowdin translations by GitHub Action (#1769)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-27 19:13:30 +08:00
github-actions[bot] b411c3a8df chore(l10n): update pot files 2026-08-27 09:12:06 +00:00
耗子 586c0df731 feat: 网站基本身份验证支持精确到目录
认证规则改为列表结构,每条规则可选择整站或指定目录生效,各自独立的
用户凭证与 htpasswd 文件。nginx 通过 http 上下文 map + auth_basic
变量实现,对静态、PHP、反向代理的所有 location 统一生效;Apache 使
用 Location 块。旧版整站配置读取时自动识别,保存后平滑迁移。

Closes #1767

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 16:51:41 +08:00
github-actions[bot] 3d1931d704 chore(l10n): update pot files 2026-08-27 05:28:41 +00:00
renovate[bot] 1df45f438b chore(deps): Update non-major dependencies (#1750)
* chore(deps): Update non-major dependencies

* fix: 适配 libtnb validator v0.4.3 与 cron v0.5.4 新 API

- 自定义规则改经 WithRules/WithFallibleRules 注入,exists/not_exists 转为
  FallibleRule 让数据库错误上抛而非静默失败
- openapi contrib 移除运行时 Op,端点文档改为 Document 闭包在定义处捕获类型
- SetDefault 已移除,Bind 校验器改由 service.SetValidator 注入
- cron.New 现在返回 error

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 23:11:15 +08:00
耗子 be938a4814 chore: 移除 unroll-variadic 采用 mockery v3 默认的可变参数整体匹配
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 17:41:19 +08:00
耗子 3ce353e8e4 chore: 升级 Go 1.27 并迁移 mockery v3 修复 CI
mockery v2 已 EOL,其内嵌的旧版 x/tools 无法解析 Go 1.27 的 export data,
导致 Mockery workflow 在 stable 切到 1.27.0 后持续失败。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 17:37:38 +08:00
耗子 7403acb521 feat: 数据表格支持表头排序
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 17:24:55 +08:00
耗子 2866d6761c feat: 文件管理地址栏呈现完整路径便于复制
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 22:09:34 +08:00
耗子 3866561d12 fix: 统一路径拼接修复根目录下文件操作报非法路径
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 22:09:31 +08:00
耗子 1c8b08707f fix: MySQL Top SQL 总耗时改用整除避免扫描类型不匹配
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:37:02 +08:00
耗子 ffab61e733 fix: 实时日志改用虚拟滚动解决大量日志卡死浏览器
- 渲染层重写为自研虚拟滚动,DOM 仅保留可视窗口内的行
- 修复弹窗内搜索输入框被 n-modal 焦点陷阱抢焦无法输入

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:22:43 +08:00
renovate[bot] 2a9592d46c chore(deps): Lock file maintenance (#1765)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 06:04:31 +00:00
耗子 f26cae527a feat: 参数调优支持按服务器画像生成推荐配置
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 06:48:07 +08:00
耗子 bf9eb06955 feat: 表维护添加 VACUUM FULL 并支持多表批量操作
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 00:08:50 +08:00
耗子 a364076e73 fix: 修正数据表格列宽适配中英文文案
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:31:06 +08:00
github-actions[bot] f1a984deb3 New Crowdin translations by GitHub Action (#1763)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-19 23:11:00 +08:00
github-actions[bot] ad98ebce57 chore(l10n): update pot files 2026-08-19 14:51:31 +00:00
耗子 3e453f4510 fix: 过滤 MEMORY DOCTOR 无诊断价值的彩蛋文案
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 22:48:17 +08:00
耗子 77aa2a3450 feat: Redis/Valkey 添加慢日志/客户端/内存诊断/大Key扫描
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 22:41:40 +08:00
耗子 0dfe577fc4 feat: Apache 添加参数调优
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 22:29:44 +08:00
耗子 af23f49811 feat: PHP 添加 FPM 进程/OPcache/Composer 管理
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 22:20:06 +08:00
耗子 d69caa6aed feat: MySQL 添加进程/事务锁/TopSQL/表维护/binlog/复制运维功能
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:43:36 +08:00
耗子 ecd403a334 feat: PostgreSQL 添加会话/TopSQL/表膨胀/WAL 运维功能
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:12:44 +08:00
github-actions[bot] a729718fd9 chore(l10n): update pot files 2026-08-19 12:42:24 +00:00
耗子 cab0e0ae2b fix: 修复 PostgreSQL 扩展页签翻译词条冲突
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:31:26 +08:00
github-actions[bot] 5c729082a5 New Crowdin translations by GitHub Action (#1762)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-19 20:26:55 +08:00
github-actions[bot] a7e17c9cbe chore(l10n): update pot files 2026-08-19 11:14:27 +00:00
耗子 6a888b14f6 fix: 精简 PostgreSQL 扩展页面提示文案
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:12:40 +08:00
github-actions[bot] 5de88d4846 chore(l10n): update pot files 2026-08-19 11:00:31 +00:00
耗子 c5a3858601 feat: PostgreSQL 扩展列表新增 11 个常用扩展
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 18:55:43 +08:00
耗子 73bc213a0f feat: PostgreSQL 添加扩展管理支持
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 18:39:34 +08:00
耗子 2dcd3dcdc6 Merge remote-tracking branch 'origin/main' 2026-08-19 18:06:02 +08:00
耗子 4e67b31d40 fix: 修复默认spa配置编号问题 2026-08-19 18:05:54 +08:00
renovate[bot] c1b1278db4 chore(deps): Lock file maintenance (#1761)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-17 05:39:14 +00:00
耗子 5391911a97 fix: 修复日志查看器多处问题
- 首屏加载后不贴底:改由 useResizeObserver 把"跟随时保持贴底"维持成不变式,
  覆盖弹窗入场、横向滚动条、字体度量、tab 由隐藏转可见等后续布局变化
- 向上翻页内容重复:文件日志改用首屏 size 作为反向分页锚点,
  不再受跟踪期间写入的新日志影响
- 首屏到建连之间的日志丢失:follow 支持从锚点字节位置续读(tail -c +N),
  重连仍只跟新增以免整段重放
- 断线重连残留的半行与新流拼接会拼出错行
- 点"跳到顶部"被翻页的位置补偿拽回原处
- 实时裁剪未退还翻页游标,导致下次上翻跳过被裁掉的那一段
- 搜索跳转改按相对位置计算,不再连带滚动外层页面
- 首屏日志不足一页时凭空出现"没有更多日志"

性能:
- ws 帧合并到 rAF 统一落盘,繁忙日志下渲染次数从帧数级降到 60/秒级
- 反向读取按字节切分,只把返回的那页转 string;首块按预估行长一次读足
- 日志行 markRaw 并移除小写副本字段

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:01:12 +08:00
耗子 335fbf697f fix: 优化翻译 2026-08-16 00:26:09 +08:00
github-actions[bot] 94d90015f4 New Crowdin translations by GitHub Action (#1760)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-15 23:28:24 +08:00
github-actions[bot] d8b2a6e574 chore(l10n): update pot files 2026-08-15 15:08:12 +00:00
耗子 d7fc81f550 fix: 日志不生效 2026-08-15 23:06:08 +08:00
耗子 6e97789db2 chore: update dependences 2026-08-15 22:53:07 +08:00
耗子 e280b05891 feat: 优化 2026-08-15 22:46:22 +08:00
耗子 bdab2c3e83 feat: 优化 2026-08-15 22:34:43 +08:00
耗子 049d5c5343 feat: Fail2ban、Rsync、Supervisor 管理器优化
Fail2ban 规则改存 jail.d/*.local,Rsync 模块改存 rsyncd.d 并由主配置 &include 引入,
两者不再靠 # name-START/END 注释标记切主配置文件。
Fail2ban 补上修改与启用开关,顺带修掉规则列表缺字段时的 index panic;
Rsync 的只读改成真开关;Supervisor 进程编辑补上可视化表单。

同时清理两轮改动:手写循环改用 samber/lo 与 slices,去掉重复的 handler 与匿名结构体,
handler 定义顺序对齐路由注册顺序,删掉复述函数名的注释。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:27:15 +08:00
耗子 a6c2a60e55 feat: Frp 管理器支持可视化配置
frps/frpc 参数表单基于 confval 新增的 TOML 编解码就地改写主配置,保留注释与键顺序。
代理与访问者写入独立的 conf.d 目录,主配置只注入一行 includes,不接管其中手写的代理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:45:30 +08:00
耗子 db88d0d20b Merge remote-tracking branch 'origin/main' 2026-08-15 21:14:34 +08:00
耗子 926a9dcac6 feat: 优化l10n流程 2026-08-15 21:14:27 +08:00
github-actions[bot] b0b1e741bd chore(l10n): update pot files 2026-08-15 13:08:42 +00:00
耗子 cafec4a3cc Merge remote-tracking branch 'origin/main' 2026-08-15 21:06:32 +08:00
耗子 8109915e20 feat: 优化CI时序 2026-08-15 21:06:22 +08:00
h2zi 19372d68ce chore: update mocks 2026-08-15 12:55:50 +00:00
耗子 237b1bfcee feat: 证书支持关联多个网站,close #1587 2026-08-15 20:54:45 +08:00
耗子 e501896943 fix: 面板更新完成后自动关闭更新页
更新成功后关闭更新页标签并跳转首页,避免刷新时停留在更新页
重复请求更新信息导致弹出「已是最新版本」错误提示

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:50:31 +08:00
耗子 e8f7811aba feat: 重构 CLI 命令并补全缺失功能
website create 未传网站类型导致必然失败,补全类型、代理地址、
数据库、备注等参数,并在进入 biz 层前统一走验证器校验。

新增命令:
- firewall status/on/off/list/port
- cert list/renew
- restore panel
- website cert/list,user create/delete
- backup list,app list,cron list/run/status
- database list-server

调整:
- 密码取值改为参数 > ACEPANEL_PASSWORD > 交互输入(不回显)
- 补 bind-domain/bind-ip/bind-ua 的 on 子命令
- 新增全局 --json,接入全部 list 命令
- port 支持 flag 写法,info 支持 -u 指定用户
- 位置参数命令补 ArgsUsage

修复:
- cutoff clear 清理远程日志时目录与上传路径不一致,导致远程切割
  日志从未被清理,ClearStorageExpired 改为接收目标目录
- restore 缺少 panel 分支,从 FixPanel 抽出 restorePanel 复用

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:30:11 +08:00
github-actions[bot] 5654ff907d New Crowdin translations by GitHub Action (#1759)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-15 18:11:42 +08:00
h2zi 6406d676be chore(l10n): update pot files 2026-08-15 02:05:58 +00:00
耗子 61e28afa31 Merge remote-tracking branch 'origin/main' 2026-08-15 10:02:44 +08:00
耗子 b070d2d0d0 chore: 支持迁移用户 2026-08-15 10:02:37 +08:00
h2zi acf90cd177 chore(l10n): update pot files 2026-08-15 01:28:04 +00:00
耗子 556dd6fb07 chore: 清理代码 2026-08-15 09:26:16 +08:00
耗子 a459c41b53 chore: 清理代码 2026-08-15 09:15:22 +08:00
耗子 2b97845b79 chore: 清理代码 2026-08-15 08:37:29 +08:00
耗子 00d3b72649 chore: 清理代码 2026-08-15 08:33:35 +08:00
耗子 e238422310 chore: 清理代码 2026-08-15 08:33:30 +08:00
耗子 dac99e9e00 chore: 清理代码 2026-08-15 07:58:26 +08:00
耗子 2bde7f7a73 chore: 清理代码 2026-08-15 07:30:59 +08:00
耗子 dbc44f1d17 Merge remote-tracking branch 'origin/main' 2026-08-15 07:08:00 +08:00
耗子 0fa22de098 feat: 添加容器数据库提醒 2026-08-15 07:07:52 +08:00
h2zi 882103bb14 chore: update mocks 2026-08-14 23:03:22 +00:00
耗子 5fbd84b039 perf: 修复告警评估拖垮服务器负载
sshd 日志改为优先读 /var/log/auth.log 或 /var/log/secure,按字节偏移增量读取,
无文本日志时才回退 journald。journald 按单位过滤要沿匹配链遍历,实测一台
3.6G journal 的机器上取最后一条都超过 30 秒,游标方案因首次定位就超时而始终
建立不起来,每轮都退回全量扫描,iowait 长期 80% 以上;调度器的 SkipIfRunning
又会让整个告警评估随之停摆。同机对比,读文本日志耗时 0.1 秒。

其余:
- 采集前先判断事件是否已订阅且配好渠道,此前是扫完才发现没人接收
- 没有系统类规则时跳过 CurrentInfo,其内部两次 cpu.Percent 各阻塞 100ms
- 清理已消失目标的静默记录,此前只清命中计数,动态目标下会持续堆积
- disk.Usage 出错返回 nil 时直接解引用会 panic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 07:02:17 +08:00
耗子 745e88df69 feat: 迁移补回传输进度日志并改进时间显示
- 传输层通过回调上报字节数,下载与上传共用一套节流与格式化,每秒最多一条;
  首秒不打,秒传的小文件无需进度,耗时过短算出的速度也没有参考价值
- 运行中显示实时累计耗时,结束后显示时间区间与总耗时,此前结束时间为空
  会渲染成两个横杠
- 步骤条末步按内容收缩,其没有连接线,再占等分宽度会在右侧留下空白
- 预检阶段提示目标缺少来源所用的 PHP 版本:宝塔从站点表的 php_version 取,
  1Panel 网站列表只给运行环境名,从运行环境列表换取版本

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 07:02:04 +08:00
耗子 7f6b86b700 perf: SSH 日志扫描改为游标增量读取
journald 对游标是直接定位,而带 -u 过滤的 --since 需沿匹配链回溯,
sshd 日志量大时非常慢。首次用 -n 0 只定位游标不输出日志,后续各轮走游标
快路径,游标因日志轮转失效时回退一轮 --since。

同时只取 MESSAGE 字段改用 -o cat,省去 journald 侧序列化与这边的逐行
反序列化,并给单轮扫描加上超时与条数上限,避免拖垮整个告警评估。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 06:18:01 +08:00
耗子 8cfb409c2f fix: 网站备份还原兼容其他面板的归档结构
还原:
- 新增 selectWebsiteBackup 逐层展开归档并定位网站文件根,用户上传宝塔或
  1Panel 导出的备份可直接还原;1Panel 的包是「元数据 + 内层归档」,
  内层再套一层站点名,其下的 index 才是文件根,PHP 站点还多一份运行环境归档
- 迁移的网站文件改走备份还原,不再自行解包,避免两处各写一套识别逻辑

1Panel 适配:
- 备份接口只是把任务丢进后台就返回,改为轮询记录状态直到写盘完成,
  此前会下载到写了一半的归档,导入时报 gzip unexpected end of file
- 网站文件根是站点目录下的 index,siteDir 是相对它的运行目录,此前少了一层
- PHP 版本改读 params 中的 PHP_VERSION,version 字段可能只有主版本号
- 反代站点的根代理与代理列表重复时以列表为准,并补全代理目标的协议
- 面板地址去掉路径部分,安全入口只拦登录页,带着它会让所有接口 404

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 06:17:52 +08:00
耗子 e9df1f28f0 fix: 修复宝塔与 1Panel 迁移的多处问题
宝塔适配:
- 认证参数改走 query,此前按 req.Method 分流,而 resty 在签名时尚未确定方法,
  导致下载备份的 GET 请求丢失认证,被面板以伪装 404 拒绝
- PHP 版本兼容 84 这类连写形式,此前解析为 0 使迁移后站点未绑定 PHP
- 反代站点改读代理模块接口,站点接口读不到新版反代规则
- 修正项目目录、启动命令与环境变量:其他项目的 project_exe 即项目目录,
  Node.js 传统与 PM2 模式取 project_file,Python 按配置直接拼出
  gunicorn/uwsgi 命令而非复用宝塔生成的配置文件
- 补齐各模块字段差异:运行用户 user、开机自启 auth、环境变量 env 与 env_list
- 站点停止时根目录被指向 /www/server/stop,改以 project_config 中保存的
  原运行目录为准

1Panel 适配:
- 自动识别 v1 与 v2,按 v2 请求失败则回落 v1 并锁定版本

迁移通用:
- 目标缺少来源 PHP 版本时退到最接近的已装版本,都没有则不启用 PHP
- CopyTree 修正为 source+"/.",filepath.Join 会清理尾部的点,
  导致临时目录被整个拷进目标

网站:
- 统计配置的 syslog tag 过滤全部非字母数字字符,站点名含点号时
  nginx 拒绝加载配置
- 放开 PHP 版本的 required_if 校验,0 表示不使用 PHP

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 05:35:03 +08:00
h2zi 89874d7cdf chore(l10n): update pot files 2026-08-14 18:23:59 +00:00
耗子 adfc71e451 fix: 优化代码 2026-08-15 00:51:10 +08:00
耗子 a32ffbe6e0 fix: 优化代码 2026-08-15 00:37:01 +08:00
耗子 7f94f80256 fix: 优化代码 2026-08-15 00:24:14 +08:00
耗子 be71610215 fix: 优化数据库迁移 2026-08-14 23:55:44 +08:00
耗子 b0ebf0f7a0 fix: lint 2026-08-14 23:13:41 +08:00
github-actions[bot] a1b042e7f7 New Crowdin translations by GitHub Action (#1758)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-14 23:06:10 +08:00
h2zi 540fa85278 chore(l10n): update pot files 2026-08-14 14:36:39 +00:00
耗子 9dac05e25c refactor: 重构网卡配置管理并加入变更确认
三个网络管理器改为 backend 接口实现,各自的辅助方法收进对应结构体,
不再由包级函数经 switch 分发;探测结果缓存,更新时只加载目标网卡。

新增两阶段提交:应用后校验配置是否真正生效,随后进入待确认状态,
30 秒内未确认则自动回滚,避免配错网卡导致远程失联无法恢复。

修复 NetworkManager 读写 MTU 未带设置名导致网卡全部不可编辑,
以及 netplan set 键含点号时 VLAN 网卡配置被写入错误层级。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:35:17 +08:00
耗子 fd20e7fad9 Merge remote-tracking branch 'origin/main' 2026-08-14 22:21:08 +08:00
耗子 e81e60189b fix: lint 2026-08-14 22:21:01 +08:00
h2zi 3a0e6e43b4 chore(l10n): update pot files 2026-08-14 14:04:59 +00:00
耗子 b81b1ba727 fix: 修复迁移中的整数窄化转换
CodeQL 报告 uint64 转 uint 在 32 位平台可能截断,改用 cast.ToUint 直接解析。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:55:03 +08:00
耗子 2a63a022e0 refactor: 重写宝塔与 1Panel 迁移
对照宝塔 11.0 与 1Panel v2 源码核实全部接口与字段,删除凭空猜测的兼容分支:
status 别名表、key/Key/k 环境变量、msg/message 双兼容、字符串二次解析、
下载重试 180 次、归档 magic 校验、残留资源清单。

按分层重新组织:service 只处理 HTTP,编排与导入下沉到 biz,两个适配器共享
HTTP 客户端,助手函数与正则收进各自结构体。容器与 Compose 迁移不再支持。

顺带修复宝塔 GetSSL 在未开启 SSL 时被误判为接口失败,以及备份完成后才恢复
来源导致停机时间过长。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:26:16 +08:00
耗子 5b6f4e50ba perf: 优化扫描数据库并修复 lint 2026-08-14 20:36:02 +08:00
h2zi 69072ac60f chore: update mocks 2026-08-14 10:58:30 +00:00
耗子 255cb98d9d perf: 优化监控与辅助数据库存储 2026-08-14 18:57:07 +08:00
耗子 1e03d5fa01 Merge remote-tracking branch 'origin/main' 2026-08-13 02:14:13 +08:00
耗子 b32cc9a6d6 fix: lint 2026-08-13 02:14:03 +08:00
h2zi faf3648a78 chore: update mocks 2026-08-12 17:17:57 +00:00
耗子 5d48412f72 feat: 支持切换网站类型 2026-08-13 01:16:51 +08:00
h2zi ddabbc2fee chore: update mocks 2026-08-12 17:12:20 +00:00
耗子 5303979097 feat: 支持宝塔和 1Panel 迁移 2026-08-13 01:11:12 +08:00
耗子 724f4d762f fix: 补充扫描感知拉黑提示 2026-08-12 20:31:51 +08:00
耗子 2fed89539a feat: 网站支持按设置监听 IPv6 2026-08-12 19:45:49 +08:00
耗子 5c55f276f1 fix: 修复 ifupdown 配置 lint 2026-08-12 19:26:19 +08:00
耗子 c3a37645fb fix(web): 修复终端固定与页面缓存 2026-08-12 19:23:00 +08:00
耗子 8aa987f71b feat: 支持网卡网络配置管理 2026-08-12 19:19:28 +08:00
耗子 987caef5de fix: 删除网站时自动解除证书关联 2026-08-12 18:46:19 +08:00
耗子 ca42c9ec16 feat: 证书部署支持快捷匹配网站 2026-08-12 18:26:37 +08:00
耗子 4d79af9513 fix: 合并连续容器端口映射并清理测试 2026-08-12 18:11:57 +08:00
耗子 17cbb1ad2a feat: Docker 操作支持后台任务 2026-08-12 17:10:54 +08:00
耗子 e0bc1f18e7 feat: 优化端口规则创建与策略修改 2026-08-12 16:15:11 +08:00
h2zi 49125becf3 chore: update mocks 2026-08-12 07:58:47 +00:00
耗子 d3ea65baf3 fix: 支持面板域名 ACME 证书 2026-08-12 15:57:43 +08:00
耗子 a37a0d7d75 feat: 支持配置编辑器快捷注释 2026-08-12 15:52:05 +08:00
renovate[bot] 930e186e58 chore(deps): Lock file maintenance (#1751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-10 04:31:07 +00:00
renovate[bot] cdfe6abfc3 chore(deps): Update non-major dependencies (#1749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-08 02:00:58 +00:00
renovate[bot] 5fe389c571 chore(deps): Update non-major dependencies (#1748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-06 18:38:05 +00:00
renovate[bot] 11c18b5e23 chore(deps): Update dependency tsx to v4.23.8 (#1747)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-05 22:05:46 +00:00
renovate[bot] 1aff534556 chore(deps): Update non-major dependencies (#1745)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-05 12:54:35 +00:00
renovate[bot] d9e3564fa2 chore(deps): Update module github.com/libtnb/sqlite to v1.2.2 (#1744)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 22:45:40 +00:00
renovate[bot] 20192fd754 chore(deps): Update non-major dependencies (#1743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 01:24:34 +00:00
renovate[bot] cd59e65744 chore(deps): Update non-major dependencies (#1742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 12:08:00 +00:00
renovate[bot] dbff2ae39c chore(deps): Update dependency tsx to v4.23.4 (#1741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-02 23:26:19 +08:00
renovate[bot] 365379551a chore(deps): Update non-major dependencies (#1740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-01 20:29:36 +00: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
耗子 37c6fadecf fix: 备份格式默认值错误 2026-05-08 21:26:23 +08:00
github-actions[bot] 122c538811 New Crowdin translations by GitHub Action (#1598)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-08 20:16:30 +08:00
h2zi 4d1bb40d26 chore(l10n): update pot files 2026-05-08 11:50:58 +00:00
耗子 f3e61dfc05 fix: lint 2026-05-08 19:49:36 +08:00
耗子 58186508bb feat: 前端现代化改造3 2026-05-08 19:26:32 +08:00
耗子 c41dab89f8 refactor: 替换 robfig/cron/v3 为 libtnb/cron
robfig/cron 已停止维护,迁移到 libtnb/cron(零三方依赖,API 设计更现代)

主要变更:
- pkg/cron/logger.go 删除,libtnb/cron 直接吃 *slog.Logger
- bootstrap.NewCron 用 wrap.Recover() / wrap.SkipIfRunning() 替代旧 cron.Recover / SkipIfStillRunning
- app.Ace.Run 处理 c.Start() error 返回值;c.Stop(ctx) 接受 context
- 6 个 job (Monitoring/FirewallScan/CertRenew/PanelTask/WebsiteStat/WebsiteExpire) 的 Run 签名从 () 改为 (context.Context) error
- jobs.Register 用 c.Add 替代 c.AddJob
- 重新生成 wire 代码

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:55:39 +08:00
耗子 2e049da24e fix: 登录页面去掉卡片 2026-05-08 18:47:37 +08:00
耗子 f6f22ecaf6 fix: zip 解压改用 7z 处理中文文件名
unzip 默认按 UTF-8 解析文件名,Windows GBK/CP936 编码的 zip 包会被转义成 #Uxxxx 形式
改用 7z x 替代,自动检测文件名编码

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:37:02 +08:00
耗子 2328456a81 fix: 修复 errcheck lint 报错
defer f.Close() 改为显式忽略返回值,跟其他地方保持一致

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:10:06 +08:00
耗子 f0bee01fd7 Merge remote-tracking branch 'origin/renovate/non-major-dependencies' 2026-05-08 15:07:05 +08:00
github-actions[bot] 4d6ac8371d New Crowdin translations by GitHub Action (#1597)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-08 14:57:20 +08:00
h2zi e5444f13d4 chore(l10n): update pot files 2026-05-07 21:32:48 +00:00
renovate[bot] ba53855292 chore(deps): Update pnpm to v11.0.5 2026-05-07 21:31:47 +00:00
renovate[bot] ab869d6b12 chore(deps): Update pnpm to v11.0.5 (#1596)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 05:31:18 +08:00
耗子 2d7fbeb370 refactor: 前端 UI 全量重构与日志查看器重写
- 引入设计系统 (tokens / naive-overrides / variables.scss),收敛颜色/圆角/阴影/字号/动效到唯一真相源,Naive UI 主色改回浅绿
- 新增系统组件 (PageContainer / ConfirmDialog / FormModal / FormDrawer 等),替换并删除 AppPage / CommonPage / DeleteConfirm
- 全量视图迁移到新组件:列表页用 NFlex 排版操作列,搜索框用 !w-XX 覆盖 NInput 默认 100% 宽
- 工具箱 SSH/日志清理/磁盘 重写为更紧凑的卡片+sub-tabs 布局
- 终端页重做:macOS 风格 shell + 紧凑 tab + 红绿状态点 + 主机 drawer
- 日志查看器重写:后端新增 GET /file/tail (反向分页) + WS /follow,前端用 anser 渲染 ANSI 颜色 + 原生 div 滚动 + WS 自动重连
- 修复多处 NPagination page-count 与 item-count 同时设置的警告
- 网站列表支持单击网站名打开编辑

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 05:30:20 +08:00
renovate[bot] 95790da784 chore(deps): Update pnpm to v11.0.4 (#1595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 00:45:59 +08:00
renovate[bot] 2ffd099c8a chore(deps): Update pnpm to v11 (#1594)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 00:42:05 +08:00
耗子 f52d324073 chore(deps): 升级 vite 到 ^8.0.11
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:44:45 +08:00
耗子 787d489cfc chore: 适配 pnpm v11 的 allowBuilds 配置
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:44:20 +08:00
renovate[bot] 407148dfe3 chore(deps): Update non-major dependencies (#1593)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 20:28:41 +08:00
github-actions[bot] 6b1eaa1aaa New Crowdin translations by GitHub Action (#1592)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-07 05:16:38 +08:00
h2zi 249424332e chore(l10n): update pot files 2026-05-06 21:16:10 +00:00
耗子 68efc98c06 fix: 用户列表与文件内联输入框未占满列宽 2026-05-07 05:13:08 +08:00
h2zi aab5029848 chore(l10n): update pot files 2026-05-06 21:01:56 +00:00
耗子 b7041f9253 feat: 进程列表左键点击弹出操作菜单 2026-05-07 05:00:18 +08:00
耗子 a5a6409be0 fix: 清除日志后实时日志未刷新 2026-05-07 04:56:01 +08:00
耗子 3eb2696690 fix: environment列表无数据时返回null 2026-05-07 04:55:57 +08:00
耗子 dbcbbf9d95 feat: 备份支持指定压缩文件格式,close #1582 2026-05-07 04:31:09 +08:00
renovate[bot] 2c078d90d7 chore(deps): Update module github.com/go-webauthn/webauthn to v0.17.2 (#1591)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 16:37:08 +00:00
耗子 f08136242e feat: 已安装应用列表显示运行状态 2026-05-06 18:05:19 +08:00
耗子 d503d9f083 fix: docker compose在目录大小情况下获取状态失败,close #1590 2026-05-06 03:14:46 +08:00
renovate[bot] 3147f70dde chore(deps): Update module github.com/gookit/color to v1.6.1 (#1589)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 21:57:48 +00:00
renovate[bot] e2815a430e chore(deps): Lock file maintenance (#1588)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 05:07:49 +00:00
renovate[bot] 36a8646485 chore(deps): Update module github.com/libtnb/sqlite to v1.1.1 (#1586)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-03 13:25:11 +00:00
renovate[bot] 901ce774d5 chore(deps): Update module github.com/fsnotify/fsnotify to v1.10.0 (#1585)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-03 05:49:35 +00:00
renovate[bot] 0a42e198dd chore(deps): Update non-major dependencies (#1584)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-02 16:35:28 +00:00
renovate[bot] 54ad4dff86 chore(deps): Update non-major dependencies (#1581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 21:57:37 +00:00
耗子 6f54b6e402 fix: De Morgan's law 2026-04-29 23:21:18 +08:00
耗子 075d9f8ee4 fix: nginx下ip证书签发失败 2026-04-29 20:27:09 +08:00
耗子 c0d8642cb5 fix: 优化代码 2026-04-28 06:05:34 +08:00
耗子 418d8af6d9 fix: 优化各列宽度 2026-04-28 04:18:39 +08:00
耗子 45b63f8cd0 fix: 容器镜像清理 2026-04-28 04:18:28 +08:00
耗子 521bf37a9f Merge remote-tracking branch 'origin/main' 2026-04-27 22:56:36 +08:00
耗子 452c5ffac8 fix: 更新错误页描述 2026-04-27 22:56:31 +08:00
h2zi f1cc5bb7d6 chore: update mocks 2026-04-27 12:31:25 +00:00
耗子 909556b3d6 refactor: 统一日志清理 2026-04-27 20:30:12 +08:00
耗子 cd64dc65f3 fix: lint 2026-04-27 19:32:20 +08:00
耗子 b3b29d79c5 fix: 优化supervisor管理器 2026-04-27 19:26:46 +08:00
耗子 d565d3bbb8 Merge remote-tracking branch 'origin/main' 2026-04-27 17:28:27 +08:00
耗子 dc62ecf1dd fix: shell 执行失败时丢失 cmd.Run 真实错误
当 cmd.Dir 不存在或 exec 启动失败时,Go 在进入 bash 前就返回错误,stderr 为空,原本只取 stderr 的错误格式会输出空 err,导致排查困难。改为同时携带 cmd.Run 的真实错误和 stderr。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:28:09 +08:00
renovate[bot] dd600d89d2 chore(deps): Lock file maintenance (#1580)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 05:13:33 +00:00
renovate[bot] 1d4a488e83 chore(deps): Update non-major dependencies (#1579)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-26 17:02:49 +00:00
renovate[bot] 71bb13cd66 chore(deps): Update non-major dependencies (#1578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-25 16:35:16 +00:00
renovate[bot] 5a52efd3ec chore(deps): Update dependency eslint-plugin-vue to v10.9.0 (#1577)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-25 06:00:31 +00:00
renovate[bot] fc8de12590 chore(deps): Update module github.com/DeRuina/timberjack to v1.4.2 (#1576)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 14:04:04 +00:00
renovate[bot] b03ad4ba86 chore(deps): Update module github.com/go-webauthn/webauthn to v0.17.0 (#1575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 14:40:33 +08:00
renovate[bot] 2be313b676 chore(deps): Update dependency eslint-plugin-oxlint to v1.61.0 (#1574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 22:35:10 +00:00
renovate[bot] 5d8a08c507 chore(deps): Update non-major dependencies (#1573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 04:09:24 +08:00
renovate[bot] ab167e884c chore(deps): Update non-major dependencies (#1572)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 04:06:33 +08:00
renovate[bot] b57602deb9 chore(deps): Update dependency vite to v8.0.9 (#1571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 08:50:16 +00:00
耗子 db8064fbe3 fix: 日志查看器改用 PTY + xterm 以支持滚动查看历史
journalctl 未加 -n 参数导致只能看到 10 行历史;watchEffect 每次日志更新
都强制滚到底部,导致用户即便有滚动条也无法回看历史。改用已有的 ws.pty +
xterm.js 基础设施取代 n-log,借助 xterm 的 scrollback buffer 获得与 SSH
下 tail 一致的交互体验。

- tail 预加载 1000 行并启用 -F 兼容日志轮转
- journalctl 加 --no-pager 防止 TTY 下启动分页器,预加载 1000 行
- 移除无用的 language prop 并同步清理 EditModal 的传参

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:45:24 +08:00
renovate[bot] aeabe9b17e chore(deps): Update dependency vue-tsc to v3.2.7 (#1570)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 23:18:48 +08:00
renovate[bot] cd65b13c0a chore(deps): Update module github.com/go-webauthn/webauthn to v0.16.5 (#1569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 18:20:09 +08:00
github-actions[bot] 51d979e63f New Crowdin translations by GitHub Action (#1568)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-22 04:48:34 +08:00
h2zi 751513d59a chore(l10n): update pot files 2026-04-21 20:38:26 +00:00
renovate[bot] 755230161b chore(deps): Update pnpm/action-setup action to v6 (#1564)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-22 03:10:21 +08:00
h2zi 13e5771445 chore: update mocks 2026-04-21 19:07:46 +00:00
Copilot 6a7c856fe8 feat: 网站到期自动关闭 (#1561)
* Initial plan

* feat: 网站到期自动关闭功能

- biz.Website 新增 ExpireAt 字段(*time.Time,可空,默认不限时)
- 新增数据库迁移自动添加 expire_at 列
- 新增 API 端点 POST /website/{id}/expire_at
- 新增定时任务每分钟检查到期网站并自动关闭
- 前端网站列表新增到期时间列(证书有效期与备注之间)

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/af33abcf-bbcd-4306-a680-76307c699774

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

* refactor: 优化到期任务查询和数据更新

- 到期检查任务直接查询已到期且运行中的网站,避免加载全量数据
- UpdateExpireAt 使用直接 UPDATE 避免不必要的 SELECT 查询

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/af33abcf-bbcd-4306-a680-76307c699774

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

* refactor: ExpireAt 改为 time.Time,日志使用英文

- ExpireAt 从 *time.Time 改为 time.Time,零值表示不限时
- 日志消息改为英文,与其他 job 文件风格保持一致
- 前端适配零值时间判断

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/00c49c64-7614-48d1-98bb-0d742d5f10ad

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

* Revert "refactor: ExpireAt 改为 time.Time,日志使用英文"

This reverts commit ac3dfdb7e4.

* fix: lint

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-04-22 03:06:43 +08:00
renovate[bot] 95a5d68f7c chore(deps): Update non-major dependencies (#1567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 01:49:37 +00:00
renovate[bot] 89bdc033cc chore(deps): Update module modernc.org/sqlite to v1.49.0 (#1566)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 02:46:21 +08:00
renovate[bot] eb83a753b6 chore(deps): Update dependency typescript to v6.0.3 (#1565)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 04:56:38 +00:00
耗子 b930c52acf Merge remote-tracking branch 'origin/main' 2026-04-17 19:01:37 +08:00
耗子 0585b7c04b fix: 批量创建PHP网站报错 2026-04-17 19:01:31 +08:00
renovate[bot] 0bc4eba46f chore(deps): Update dependency eslint-plugin-oxlint to v1.60.0 (#1563)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 22:44:52 +00:00
renovate[bot] f015404523 chore(deps): Update non-major dependencies (#1562)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 22:56:51 +08:00
耗子 2a2e413e45 chore: update deps 2026-04-16 22:48:52 +08:00
Copilot 1f9407b25a fix: WebDAV 存储连接失败 — 前端表单字段与后端不匹配 (#1560)
* Initial plan

* fix: WebDAV 存储连接失败,前端表单字段绑定错误 (host → url)

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/6bc5c598-6439-49f4-92d7-59a0640d6148

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

* fix: 将 WebDAV 标签 URL 包装 $gettext() 保持国际化一致性

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/6bc5c598-6439-49f4-92d7-59a0640d6148

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>
2026-04-16 18:37:50 +08:00
renovate[bot] d09a62e175 chore(deps): Update dependency @vitejs/plugin-vue to v6.0.6 (#1559)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 18:28:31 +08:00
耗子 22102b5dc7 Merge remote-tracking branch 'origin/main' 2026-04-16 15:15:19 +08:00
耗子 fcf4544224 fix: 不支持用户管理的数据库不显示 2026-04-16 15:15:13 +08:00
Copilot d301f045b1 feat: 应用更新确认弹窗显示目标版本号 (#1556)
Agent-Logs-Url: https://github.com/acepanel/panel/sessions/4897ec9f-a2e8-42bf-a691-3e1cf14c7d0c

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>
2026-04-13 16:41:38 +08:00
耗子 694847e558 Merge remote-tracking branch 'origin/main' 2026-04-13 16:33:58 +08:00
耗子 02fecb35de fix: 使用WEB服务器下 IPv6 证书签发问题 2026-04-13 16:33:52 +08:00
renovate[bot] d58577205e chore(deps): Lock file maintenance (#1555)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 04:58:05 +00:00
renovate[bot] 316bd54bc0 chore(deps): Update non-major dependencies (#1554)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 01:54:23 +00:00
renovate[bot] 2209757a62 chore(deps): Update non-major dependencies (#1553)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 01:34:59 +08:00
耗子 59a7f01f95 feat: Grafana添加更多数据源 2026-04-10 22:36:00 +08:00
耗子 5b8b0f0d07 feat: 移除buypass 2026-04-10 02:06:17 +08:00
耗子 1a4b377c22 fix: DNS别名映射问题 2026-04-10 00:16:44 +08:00
renovate[bot] 837aaab151 chore(deps): Lock file maintenance (#1552)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-09 22:48:20 +08:00
renovate[bot] 168dd7071e chore(deps): Update non-major dependencies (#1551)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-09 22:45:31 +08:00
github-actions[bot] b7e50aaf1d New Crowdin translations by GitHub Action (#1550)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-09 19:42:43 +08:00
h2zi cf6a6c80c1 chore(l10n): update pot files 2026-04-09 11:26:08 +00:00
耗子 a2f39d104c Merge remote-tracking branch 'origin/main' 2026-04-08 23:55:23 +08:00
耗子 97c8ad8356 feat: DNS别名使用完整域名 2026-04-08 23:55:17 +08:00
renovate[bot] 489e0702c3 chore(deps): Update module github.com/go-webauthn/webauthn to v0.16.3 (#1549)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 15:22:53 +08:00
耗子 db2bb0606d feat: 优化代码 2026-04-07 19:05:40 +08:00
github-actions[bot] a2d1c9c535 New Crowdin translations by GitHub Action (#1548)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-07 19:00:21 +08:00
h2zi f6ab265b6b chore(l10n): update pot files 2026-04-07 10:35:11 +00:00
github-actions[bot] 4614fb1cd1 New Crowdin translations by GitHub Action (#1547)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-04-07 15:36:49 +08:00
h2zi 965525e68e chore(l10n): update pot files 2026-04-07 06:49:24 +00:00
耗子 fdde61c8ae feat: DNS-01 CNAME 委托验证 (#1546)
* feat: DNS-01 CNAME 委托验证

* fix: address comments

* fix: lint
2026-04-07 03:45:41 +08:00
耗子 99bc8ddc8d Merge remote-tracking branch 'origin/main' 2026-04-07 03:17:46 +08:00
耗子 d4f85066a0 feat: 计划任务支持备份指定目录功能 (#1540)
在计划任务的备份类型中新增"目录"子类型,支持将任意指定目录打包压缩后
备份到本地或远程存储,并自动清理过期备份。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 02:56:29 +08:00
dependabot[bot] 5263ec4a46 chore(deps-dev): bump vite from 8.0.3 to 8.0.5 in /web (#1544)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5.
- [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.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  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-04-07 02:41:37 +08:00
h2zi 522d78569a chore: update mocks 2026-04-06 18:20:12 +00:00
耗子 a42f9f63d0 refactor: 重构证书签发实现 (#1543)
* refactor: 重构证书签发实现

* fix: address comments

* fix: optimize code
2026-04-07 02:19:22 +08:00
renovate[bot] 7a7e8c4460 chore(deps): Update module github.com/lib/pq to v1.12.3 (#1542)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 01:41:11 +08:00
zenmediasocial 116fbdda89 fix: DNS-01 挑战缺少传播等待导致 NXDOMAIN 验证失败 (#1536)
dnsSolver 未实现 acmez 的 Waiter 接口,导致 Present() 向 Cloudflare
写入 TXT 记录后,acmez 立即通知 CA 进行验证,而 DNS 记录尚未在权威
解析服务器上生效,Let's Encrypt 查询时返回 NXDOMAIN,授权失败。

修复内容:
- 为 dnsSolver 添加 Wait() 方法(实现 acmez.Waiter),在通知 CA 验证
  前等待 60 秒,确保 DNS 记录传播完成
- 将 records 字段的赋值改为 append,避免多域名证书场景下并发
  Present() 调用互相覆盖记录
- 添加 sync.Mutex 保护 records 字段的并发读写
- 放宽 SetRecords 结果数量检查(!= 1 改为 == 0),兼容更新已有记录时
  返回多条结果的情况
- 将 Renew 超时从 2 分钟延长至 10 分钟,避免加入等待后超时

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:50:46 +08:00
耗子 87b728ca1b fix: lint 2026-04-07 00:43:34 +08:00
renovate[bot] 2320656d2b chore(deps): Update non-major dependencies (#1541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 21:46:49 +08:00
renovate[bot] 2e5d67e4b0 chore(deps): Lock file maintenance (#1539)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 14:47:55 +08:00
renovate[bot] 1c9ed32aaf chore(deps): Update dependency sass to v1.99.0 (#1538)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 04:47:21 +00:00
renovate[bot] 9df648b7e9 chore(deps): Update non-major dependencies (#1537)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-05 13:43:32 +00:00
Copilot 1079065220 feat: 为应用页面添加"已安装"Tab选项卡 (#1535)
* Initial plan

* feat: 为应用页面添加"已安装"Tab选项卡

- 后端:app/list 和 environment/list 添加 installed 过滤参数
- 前端:创建 InstalledView 组件展示已安装的应用和环境
- 前端:IndexView 添加"已安装"Tab 并设为默认选项

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/9b460f4b-c69e-4a5b-8131-c68099edcb7c

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

* refactor: 移除 InstalledView 中未使用的 VersionModal 组件

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/9b460f4b-c69e-4a5b-8131-c68099edcb7c

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>
2026-04-03 16:15:30 +08:00
Copilot 6f8c1b410f feat: 文件编辑器未保存更改对话框添加放弃选项 (#1534)
* Initial plan

* feat: 文件编辑器保存提示添加返回/放弃/保存三种操作

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/7127d3cc-b379-4b25-ae66-d1847f139551

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

* fix: 标签页保存失败时显示错误提示消息

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/7127d3cc-b379-4b25-ae66-d1847f139551

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>
2026-04-03 16:13:53 +08:00
耗子 6e63f565fa Merge remote-tracking branch 'origin/main' 2026-04-03 14:53:46 +08:00
耗子 c24fa4580e fix: 数据库防止酒吧点炒饭
数据库名和用户名校验正则禁止首位为数字,因为数据库不接受库名/用户名以数字开头。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:51:14 +08:00
renovate[bot] 2e6277848c chore(deps): Update module github.com/go-webauthn/webauthn to v0.16.2 (#1531)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 01:19:36 +00:00
renovate[bot] 35f9d16d81 chore(deps): Update module github.com/lib/pq to v1.12.1 (#1530)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-02 17:28:44 +00:00
renovate[bot] fcf0d49cba chore(deps): Update module github.com/hashicorp/go-version to v1.9.0 (#1529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-02 22:34:32 +08:00
耗子 9c0f5d86d2 Merge remote-tracking branch 'origin/main' 2026-04-02 20:56:47 +08:00
耗子 97161b8628 feat: 优化PostgreSQL数据库用户删除 2026-04-02 20:56:40 +08:00
renovate[bot] 0b131b2920 chore(deps): Update dependency lodash-es to v4.18.1 [SECURITY] (#1528)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-02 17:45:19 +08:00
耗子 37c2f851e5 feat: 去除eslint 2026-04-01 23:19:29 +08:00
耗子 9ec7b47cf6 feat: 前端现代化改造3 2026-04-01 23:01:06 +08:00
耗子 a4c59d29af feat: 前端现代化改造2 2026-04-01 22:58:39 +08:00
耗子 a5396fddbb feat: 前端现代化改造 2026-04-01 22:52:58 +08:00
耗子 bc2584b7e5 feat: 前端现代化改造 2026-04-01 22:46:54 +08:00
renovate[bot] d839949684 chore(deps): Update module github.com/shirou/gopsutil/v4 to v4.26.3 (#1526)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 17:42:09 +08:00
renovate[bot] 3d81d1d0a3 chore(deps): Update module github.com/andybalholm/brotli to v1.2.1 (#1525)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 01:24:39 +00:00
renovate[bot] 0debf14dc3 chore(deps): Update module modernc.org/sqlite to v1.48.0 (#1524)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 02:21:11 +08:00
耗子 fc84ba4e86 Merge remote-tracking branch 'origin/main' 2026-03-31 22:01:56 +08:00
耗子 7acd24c587 fix: 移除误装的垃圾包 2026-03-31 22:01:51 +08:00
renovate[bot] 4275baabd0 chore(deps): Update module github.com/gookit/validate to v1.5.7 (#1522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:39:46 +00:00
耗子 e6657c10e2 fix: postgres配置转义 2026-03-31 19:18:14 +08:00
耗子 9e5cc546c1 Merge remote-tracking branch 'origin/main' 2026-03-30 20:28:17 +08:00
耗子 e799cb4031 feat: 重构面板证书逻辑 2026-03-30 20:28:11 +08:00
renovate[bot] 12a6d6f39f chore(deps): Lock file maintenance (#1521)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 04:37:29 +00:00
耗子 c33c03d225 fix: ufw默认开启转发,close #1519 2026-03-28 19:45:48 +08:00
耗子 715e58c122 feat: 面板证书可以为空 2026-03-28 19:44:53 +08:00
耗子 9f8d684763 fix: grafana配置修改 2026-03-28 16:32:59 +08:00
耗子 1d79f8c06e fix: 修改推荐的图表 2026-03-28 00:21:52 +08:00
耗子 3fa4921b40 fix: mongodb 修改密码权限问题 2026-03-27 23:10:06 +08:00
github-actions[bot] 08bb081c66 New Crowdin translations by GitHub Action (#1518)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-03-27 22:27:17 +08:00
h2zi 4aa95c77f9 chore(l10n): update pot files 2026-03-27 14:24:47 +00:00
github-actions[bot] 5ccaa58ccb New Crowdin translations by GitHub Action (#1482)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-03-27 21:56:12 +08:00
h2zi 1ac12b4ebd chore(l10n): update pot files 2026-03-27 13:05:45 +00:00
耗子 32067e6000 feat: 添加exporter 2026-03-27 20:57:04 +08:00
耗子 37fc3b29fa Merge remote-tracking branch 'origin/main' 2026-03-27 20:33:39 +08:00
耗子 e2ce8e70f1 fix: optimize code 2026-03-27 20:32:31 +08:00
h2zi 60d4e31fac chore(l10n): update pot files 2026-03-27 12:14:27 +00:00
耗子 49b6c5af7e feat: 添加apcu模块,close #1517 2026-03-27 20:11:01 +08:00
耗子 76f80a8e65 feat: 修改PHP模块目录 2026-03-27 20:01:25 +08:00
耗子 dddb718fda feat: 更新安全说明 2026-03-27 19:58:16 +08:00
耗子 046dc09f69 feat: 更新安全说明 2026-03-27 19:56:25 +08:00
耗子 8d218e7e88 feat: 更新安全说明 2026-03-27 19:56:08 +08:00
耗子 5fdae42eb7 feat: 更新安全说明 2026-03-27 19:55:18 +08:00
耗子 d01d14f35e feat: 支持秒级计划任务,close #1511 2026-03-27 19:20:41 +08:00
耗子 5e251ac04a fix: lint 2026-03-27 17:25:18 +08:00
耗子 b12590c1c2 fix: lint 2026-03-27 17:13:23 +08:00
h2zi 4c6347d6c3 chore: update mocks 2026-03-27 09:00:20 +00:00
耗子 f66b094ec6 feat: 添加elasticsearch和opensearch 2026-03-27 16:59:24 +08:00
耗子 b0e04d2976 feat: clickhouse优化 2026-03-27 16:20:37 +08:00
耗子 68b96be537 feat: 添加clickhouse和mongodb的管理 2026-03-27 15:43:18 +08:00
耗子 8ebe63ac3d feat: 添加clickhouse和mongodb的管理 2026-03-27 15:09:06 +08:00
耗子 4df9400fb2 fix: optimize code 2026-03-27 06:11:47 +08:00
耗子 c40da3895d Merge remote-tracking branch 'origin/copilot/fix-uuid-issue-and-check-code' 2026-03-27 06:06:05 +08:00
耗子 ad409eb446 Merge remote-tracking branch 'origin/main' 2026-03-27 06:05:34 +08:00
耗子 7c7d5b6ce1 feat: 添加clickhouse和mongodb 2026-03-27 06:03:55 +08:00
renovate[bot] 5de7a8e745 chore(deps): Update codecov/codecov-action action to v6 (#1516)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 03:39:46 +08:00
copilot-swe-agent[bot] 2f9ccf23a0 fix(web): 降级HTTP环境的tab id与SSH粘贴读取
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Agent-Logs-Url: https://github.com/acepanel/panel/sessions/12e3737b-640d-48e1-942b-dc17ae98ad4a
2026-03-26 14:57:16 +00:00
copilot-swe-agent[bot] 3a7099b3d2 fix(web): 替换不依赖HTTPS的tab id并修复SSH剪贴板读取降级
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Agent-Logs-Url: https://github.com/acepanel/panel/sessions/12e3737b-640d-48e1-942b-dc17ae98ad4a
2026-03-26 14:52:47 +00:00
copilot-swe-agent[bot] e05202dafb Initial plan 2026-03-26 14:38:14 +00:00
renovate[bot] f8c99803ed chore(deps): Update dependency vite to v8.0.3 (#1514)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 12:38:13 +00:00
renovate[bot] 3439783518 chore(deps): Update non-major dependencies (#1513)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 21:06:51 +00:00
renovate[bot] c7929c2899 chore(deps): Update github.com/gookit/validate digest to d265741 (#1512)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 08:51:02 +00:00
renovate[bot] 0d6a1b384d chore(deps): Update non-major dependencies (#1509)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 00:58:22 +00:00
耗子 9216940d90 feat: 添加rocketmq和kafka 2026-03-25 03:12:18 +08:00
耗子 d1371b2bb1 feat: 添加valkey、elasticsearch和opensearch 2026-03-25 03:06:26 +08:00
耗子 e00013f801 feat: 添加valkey、elasticsearch和opensearch 2026-03-25 02:58:15 +08:00
耗子 130716216d feat: 添加alertmanager配置 2026-03-25 01:51:55 +08:00
耗子 9d22b5d1d0 feat: 添加推荐的仪表盘ID 2026-03-25 01:44:20 +08:00
耗子 b952ebf5ad fix: 修复问题 2026-03-25 01:17:05 +08:00
耗子 455f6c061d feat: 优化grafana 2026-03-25 01:06:32 +08:00
耗子 432b0662b0 chore: add svg logo 2026-03-24 22:59:54 +08:00
耗子 0ae7327105 feat: 添加prometheus和grafana支持 (#1508)
* feat: 阶段性提交

* feat: 完成开发

* feat: 阶段性提交

* feat: 阶段性提交
2026-03-24 22:45:22 +08:00
renovate[bot] 0870b100cc chore(deps): Update dependency @vue/tsconfig to v0.9.1 (#1507)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 06:15:18 +00:00
renovate[bot] 28b2eaa638 chore(deps): Update dependency unplugin-vue-components to v32 (#1504)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 03:40:29 +08:00
renovate[bot] bdcdfbf96e chore(deps): Update dependency vite-plugin-vue-devtools to v8.1.1 (#1505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 18:00:50 +00:00
renovate[bot] 471db7ef69 chore(deps): Update non-major dependencies (#1503)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 22:43:09 +08:00
renovate[bot] be67988272 chore(deps): Lock file maintenance (#1502)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 05:07:39 +00:00
renovate[bot] a56ef041c8 chore(deps): Update dependency vite-plugin-compression2 to v2.5.3 (#1500)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-21 17:31:05 +00:00
renovate[bot] 3b9c1757a0 chore(deps): Update module github.com/libtnb/sqlite to v1.0.1 (#1499)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-21 05:42:33 +00:00
耗子 ed06f53791 fix: lint 2026-03-21 04:30:00 +08:00
Copilot ff06049085 修复 UFW 端口转发未下发/清理 route allow 规则 (#1498)
* Initial plan

* fix: add ufw route allow/delete for port forwarding

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
2026-03-20 18:34:47 +08:00
renovate[bot] b9b00a0117 chore(deps): Update dependency vue-router to v5.0.4 (#1497)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 02:09:32 +08:00
renovate[bot] 61572b0a3c chore(deps): Update dependency vite to v8.0.1 (#1496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-19 13:24:57 +00:00
renovate[bot] 8fdbd19061 chore(deps): Update module github.com/lib/pq to v1.12.0 (#1495)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 21:35:38 +00:00
renovate[bot] c5e7084c8b chore(deps): Update non-major dependencies to v66.6.7 (#1494)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 16:56:42 +08:00
renovate[bot] 71129d6446 chore(deps): Update dependency vue-tsc to v3.2.6 (#1492)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 01:51:17 +08:00
renovate[bot] 615df5966b chore(deps): Update pnpm/action-setup action to v5 (#1493)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 01:50:43 +08:00
renovate[bot] d15ccba931 chore(deps): Update github.com/gookit/validate digest to 350536c (#1491)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 05:41:38 +00:00
renovate[bot] d0ac218809 chore(deps): Lock file maintenance (#1490)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 05:27:14 +00:00
renovate[bot] f0cf40c5f0 chore(deps): Update dependency vite-plugin-compression2 to v2.5.2 (#1489)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 21:13:38 +00:00
Copilot b100922bc7 fix: 文件上传覆盖运行中的二进制文件时 ETXTBSY 错误 (#1488)
* Initial plan

* fix: 强制覆盖文件时先删除再创建,避免覆盖运行中的二进制文件报 ETXTBSY 错误

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 处理 Remove() 错误,删除失败时提前返回明确错误信息

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-03-15 20:09:03 +08:00
Copilot 69ff73a393 feat: 为应用管理(原生应用、运行环境和容器模板)添加搜索功能 (#1487)
* Initial plan

* feat: 为应用管理(原生应用、运行环境和容器模板)添加搜索功能

后端通过 GET query 参数传入关键词,按名称和描述进行大小写不敏感过滤。
容器模板额外支持按 website 字段搜索。
前端在分类标签旁添加搜索输入框,输入变化时自动触发重新查询。

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: 优化搜索过滤代码格式和 TemplateView 一致性

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
2026-03-15 18:07:28 +08:00
Copilot 01585dcc3b fix: preserve YAML-defined order for container template environment variables (#1486)
* Initial plan

* fix: 保留容器模板环境变量的 YAML 定义顺序

使用 yaml.Node 从原始 YAML 数据中提取环境变量键的定义顺序,
替代之前的字母序排序,确保前端展示的字段顺序与 data.yml 一致。

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-03-15 17:06:37 +08:00
Copilot fe5daca28b fix: 修复文件粘贴重命名时弹窗无限循环的回归问题 (#1483)
* Initial plan

* fix: 修复文件粘贴重命名时弹窗无限循环的回归问题

修复 PR #1474 引入的回归问题:
1. handleRename 完成后不再调用 checkAndExecute(避免重新弹出警告弹窗导致无限循环),
   改为直接 await file.exist() 检查冲突并在 while 循环内重新提示重命名
2. 重命名弹窗增加源文件路径显示,便于区分多个冲突文件

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
2026-03-15 03:48:48 +08:00
h2zi 3be7ec8a80 chore(l10n): update pot files 2026-03-14 19:18:16 +00:00
renovate[bot] 6426d2e7d2 chore(deps): Update non-major dependencies (#1481)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-13 09:20:14 +00:00
renovate[bot] 663a303c2b chore(deps): Update dependency vite to v8 (#1480)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-13 06:29:04 +08:00
renovate[bot] 1ce4628d87 chore(deps): Update non-major dependencies (#1479)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 21:45:43 +00:00
耗子 d45ddac1cc fix: eBPF UDP 扫描检测过滤已知服务端口响应流量,避免 DNS/NTP 等回包误报
UDP 无连接态,无法像 TCP 靠 SYN 标志区分新连接和响应。系统发起 DNS 查询后,
响应包(源端口 53)到达临时端口,因不在监听白名单中被误报为扫描。

在 UDP 处理器中增加源端口检查,跳过已知服务端口的响应流量:
DNS(53), DHCP(67/68), NTP(123), QUIC(443), IKE(500),
DHCPv6(546/547), DoQ(853), IPsec NAT-T(4500), mDNS(5353)

Closes #1478

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:59:41 +08:00
renovate[bot] 820b318c00 chore(deps): Update module golang.org/x/net to v0.52.0 (#1477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 08:46:30 +00:00
renovate[bot] 516f552181 chore(deps): Update module golang.org/x/crypto to v0.49.0 (#1476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 01:15:42 +00:00
github-actions[bot] 228e2146ba New Crowdin translations by GitHub Action (#1475)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-03-12 03:18:00 +08:00
devhaozi 007f7339f7 chore(l10n): update pot files 2026-03-11 17:56:47 +00:00
Copilot 613ec00c90 feat: add rename option for file copy/move conflicts, fix same-directory paste error (#1474)
* Initial plan

* feat: 文件复制/移动冲突时添加重命名选项,修复同目录粘贴覆盖报错

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: 移除不必要的 ref,改用 defaultValue 并支持 Enter 键确认重命名

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: use const for DialogReactive and remove unused import to pass lint

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
2026-03-12 01:55:41 +08:00
github-actions[bot] 8071ea1757 New Crowdin translations by GitHub Action (#1472)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-03-12 01:04:38 +08:00
耗子 6da51ed0eb fix: build 2026-03-12 00:56:48 +08:00
耗子 aa1751f0bb Merge remote-tracking branch 'origin/main' 2026-03-12 00:54:42 +08:00
耗子 f15841f04a fix: build 2026-03-12 00:54:38 +08:00
renovate[bot] ced266145b chore(deps): Update dependency vite-plugin-compression2 to v2.5.1 (#1471)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 00:46:17 +08:00
h2zi 580a68202c chore(l10n): update pot files 2026-03-11 16:24:16 +00:00
renovate[bot] 81e99db86e chore(deps): Update module github.com/DeRuina/timberjack to v1.4.0 (#1470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 00:22:23 +08:00
耗子 499a570efa fix: 移除ssl密码套件配置,因为aws-lc似乎不支持 2026-03-12 00:21:02 +08:00
耗子 5258ef9d00 Merge remote-tracking branch 'origin/main' 2026-03-11 21:58:21 +08:00
耗子 c34203ae7b fix: 优化代码 2026-03-11 21:58:17 +08:00
renovate[bot] d477d1c637 chore(deps): Update non-major dependencies (#1469)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-11 06:43:29 +00:00
renovate[bot] 10c98c5bb5 chore(deps): Update non-major dependencies (#1468)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 10:16:24 +00:00
renovate[bot] d8e8a305d3 chore(deps): Update dependency vue to v3.5.30 (#1466)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 17:47:56 +00:00
renovate[bot] 2dba43fd20 chore(deps): Lock file maintenance (#1465)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 05:23:54 +00:00
耗子 b5a65e6226 Merge remote-tracking branch 'origin/main' 2026-03-09 06:28:58 +08:00
耗子 7295baa2d5 chore: update 2026-03-09 06:28:53 +08:00
renovate[bot] 2c9b484218 chore(deps): Update dependency naive-ui to v2.44.1 (#1464)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 20:51:45 +00:00
renovate[bot] 0ac71f9f8a chore(deps): Update dependency naive-ui to v2.44.0 (#1462)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 13:41:26 +00:00
耗子 2217d2ba33 Merge remote-tracking branch 'origin/main' 2026-03-08 17:23:03 +08:00
耗子 f94345dd1c fix: 容器终端兼容sh 2026-03-08 17:22:59 +08:00
renovate[bot] 17eb9c6b63 chore(deps): Update pnpm to v10.31.0 (#1461)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 05:21:23 +00:00
github-actions[bot] 0104418c10 New Crowdin translations by GitHub Action (#1460)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-03-08 01:03:47 +08:00
h2zi 24b0fb1a12 chore(l10n): update pot files 2026-03-07 16:54:14 +00:00
Copilot e9c1a946e9 feat: 支持从本地目录加载容器模板(兼容 acepanel/templates 仓库格式) (#1452)
* Initial plan

* feat: 支持从本地目录加载容器模板

从 {app.Root}/templates/ 目录加载 JSON 格式的本地模板文件,
本地模板可覆盖同 slug 的远端模板。
UI 上使用 warning 标签标识本地模板。
本地模板部署时跳过远端回调。

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: 为本地模板加载添加 slog 日志记录

目录读取失败时记录警告日志,
模板文件读取/解析/缺失 slug 时记录警告日志。

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: 本地模板改用与 acepanel/templates 仓库一致的目录格式

从 JSON 文件格式改为目录格式:每个模板是一个子目录,
包含 data.yml + docker-compose.yml + 可选 logo.svg/logo.png。
data.yml 支持 i18n 字段,环境变量使用 map 格式。
与 github.com/acepanel/templates 仓库格式完全一致。

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* chore: 改进 resolveLocale 注释

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* plan: address review feedback

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: 将模板数据类型移至 pkg/types 并清理注释括号

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 注入 slog.Logger、方法挂载到 templateRepo、简化注释

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-03-08 00:51:51 +08:00
耗子 1afedfc4d8 Merge remote-tracking branch 'origin/main' 2026-03-08 00:42:14 +08:00
耗子 971aaddc85 feat: 计划任务运行周期支持重启后,close #1456 2026-03-08 00:42:09 +08:00
h2zi 60052aac09 chore: update mocks 2026-03-07 16:28:46 +00:00
耗子 4a6fdc45d5 feat: 部署证书时支持开启 HTTPS 及一键签发证书泛域名 DNS 选择 2026-03-08 00:27:55 +08:00
耗子 2be464777b fix: 优化证书账号显示 2026-03-07 22:06:52 +08:00
renovate[bot] 7ea9f5a340 chore(deps): Update dependency @eslint/eslintrc to v3.3.5 (#1459)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 05:45:17 +00:00
renovate[bot] 2142c4fd67 chore(deps): Update non-major dependencies (#1458)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-06 19:21:05 +08:00
renovate[bot] f3e9d20990 chore(deps): Update non-major dependencies (#1457)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-05 21:50:42 +00:00
renovate[bot] f1c17c4651 chore(deps): Update non-major dependencies to v66.6.5 (#1455)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 13:19:22 +00:00
renovate[bot] 8ecdaf3c7a chore(deps): Update non-major dependencies to v66.6.4 (#1453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 17:41:32 +00:00
Copilot bb2f9e02b5 fix: env var with empty string default treated as required in template deploy (#1451)
* Initial plan

* fix: 修复容器模板环境变量空字符串默认值被误判为必填项

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
2026-03-03 21:01:50 +08:00
renovate[bot] bbe0e9c4b4 chore(deps): Update module github.com/samber/lo to v1.53.0 (#1447)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 22:18:51 +00:00
github-actions[bot] 9925a51cde New Crowdin translations by GitHub Action (#1446)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-03-02 22:42:27 +08:00
h2zi cb29a24751 chore(l10n): update pot files 2026-03-02 14:40:59 +00:00
耗子 5f62c6dd29 Merge remote-tracking branch 'origin/main' 2026-03-02 22:38:24 +08:00
耗子 8647c10f3f fix: session及计划任务优化 2026-03-02 22:38:19 +08:00
renovate[bot] a04ac69392 chore(deps): Update non-major dependencies (#1445)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 19:49:51 +08:00
耗子 b011191d4c Merge remote-tracking branch 'origin/main' 2026-03-02 19:31:00 +08:00
耗子 e00c1eb1f7 fix: 文件管理path可能未定义的问题 2026-03-02 19:30:49 +08:00
renovate[bot] 597c374443 chore(deps): Lock file maintenance (#1444)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 09:29:50 +00:00
renovate[bot] 0a34d5f631 chore(deps): Update non-major dependencies (#1443)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 02:17:51 +00:00
renovate[bot] 36b8500101 chore(deps): Update non-major dependencies (#1442)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 16:06:01 +08:00
renovate[bot] 5044060661 chore(deps): Update module github.com/shirou/gopsutil/v4 to v4.26.2 (#1440)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 15:58:58 +08:00
Catsayer d96d83a5b7 合并拉取请求 #1441
*  refactor(service): 抽取环境 CLI 软链接公共能力并统一 SetCli 实现

* fix: 优化代码
2026-03-01 15:58:29 +08:00
github-actions[bot] 99fe0b2719 New Crowdin translations by GitHub Action (#1439)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-28 23:10:15 +08:00
h2zi 06bd74be8c chore(l10n): update pot files 2026-02-28 15:08:27 +00:00
renovate[bot] fe0d709370 chore(deps): Update non-major dependencies (#1438)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-28 23:07:19 +08:00
耗子 e0f5704275 fix: 竞态条件导致通行密钥无法登录 2026-02-28 23:04:28 +08:00
renovate[bot] 4eb80c8720 chore(deps): Update dependency @types/node to v24.11.0 (#1437)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-28 16:57:21 +08:00
耗子 d8213d65a0 fix: lint 2026-02-28 00:58:24 +08:00
github-actions[bot] f00ceb9d10 New Crowdin translations by GitHub Action (#1436)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-28 00:42:41 +08:00
h2zi 8f96d53e09 chore(l10n): update pot files 2026-02-27 16:20:14 +00:00
耗子 22cfddf43b fix: postgresql 2026-02-27 19:19:17 +08:00
耗子 4479e5b9e9 Merge remote-tracking branch 'origin/main' 2026-02-27 17:29:36 +08:00
耗子 dbe4ea9909 feat: 兼容418错误 2026-02-27 17:29:24 +08:00
renovate[bot] 9de68220ec chore(deps): Update dependency @types/node to v24.10.15 (#1434)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-27 05:44:56 +08:00
renovate[bot] 83de33d1ab chore(deps): Update GitHub Artifact Actions (#1435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-27 05:44:46 +08:00
耗子 d8515f9883 feat: 监控采集间隔支持设置 2026-02-27 00:11:07 +08:00
h2zi 4ff7ce18cd chore: update mocks 2026-02-26 15:51:13 +00:00
耗子 71e259ba32 feat: 支持通行密钥登录 (#1432)
* feat: 支持通行密钥登录

* fix: 修复AI评论

* Update pkg/passkey/passkey.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: lint

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-26 23:50:18 +08:00
renovate[bot] 4873103904 chore(deps): Update pnpm to v10.30.3 (#1431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 14:57:57 +00:00
耗子 55655d99cf Revert "fix: lint"
This reverts commit a67ec47c7e.
2026-02-26 16:18:05 +08:00
github-actions[bot] 830d4dca7a New Crowdin translations by GitHub Action (#1430)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-26 16:15:05 +08:00
devhaozi 2a89e060c3 chore(l10n): update pot files 2026-02-26 08:02:42 +00:00
耗子 6900092723 Merge remote-tracking branch 'origin/main' 2026-02-26 16:00:31 +08:00
耗子 8a79d90e78 fix: lint 2026-02-26 16:00:28 +08:00
renovate[bot] f5e5b687b8 chore(deps): Update non-major dependencies to v66.6.2 (#1429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 15:54:58 +08:00
renovate[bot] f5628cdf4a chore(deps): Update non-major dependencies (#1428)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 15:50:55 +08:00
耗子 44f569b478 feat: 扫描感知支持自动屏蔽IP 2026-02-26 04:04:02 +08:00
耗子 38ec8b63d2 feat: 扫描感知支持自动屏蔽IP 2026-02-26 03:58:06 +08:00
耗子 fad297f33d fix: PostgreSQL备份清理 2026-02-26 00:18:02 +08:00
耗子 0c8a8cd382 fix: lint 2026-02-25 23:08:47 +08:00
耗子 3089a847a7 fix: 编辑数据库服务器时 Redis 类型隐藏用户名输入框
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:51:36 +08:00
renovate[bot] 4b1839169a chore(deps): Update non-major dependencies to v66.6.1 (#1426)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 22:51:01 +08:00
github-actions[bot] 2aeec54189 New Crowdin translations by GitHub Action (#1425)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-25 21:44:24 +08:00
h2zi ce19d4ff14 chore(l10n): update pot files 2026-02-25 13:42:23 +00:00
耗子 a67ec47c7e fix: lint 2026-02-25 21:41:01 +08:00
耗子 7fc0e0f4af fix: Docker socket 路径错误和 Redis 密码管理问题 (#1424)
1. Docker 默认 socket 路径不应拼接面板根目录前缀
2. Redis Load() 正则缺少多行标记导致无法提取密码
3. Redis 修改 requirepass 后未同步到数据库服务器记录

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:32:26 +08:00
耗子 bfd4896470 Merge remote-tracking branch 'origin/main' 2026-02-25 18:08:54 +08:00
耗子 12b8a4a03b chore: run go generate ./... 2026-02-25 18:08:32 +08:00
h2zi d4c853db01 chore: update mocks 2026-02-25 10:07:42 +00:00
半岛的蒟蒻bddjr e947259b86 fix: go.mod module github.com/acepanel/panel/v3 (#1423) 2026-02-25 18:07:00 +08:00
renovate[bot] c7cde220d4 chore(deps): Update non-major dependencies (#1422)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 21:41:34 +08:00
耗子 b4e7c924a9 feat: 默认禁用扫描感知 2026-02-24 21:01:10 +08:00
耗子 6401f2ddd8 fix: postgresql数据库无法创建备份任务 2026-02-23 23:19:00 +08:00
耗子 735258c212 feat: 调整排序 2026-02-23 22:26:56 +08:00
github-actions[bot] 358ee8d19c New Crowdin translations by GitHub Action (#1421)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-23 21:33:06 +08:00
h2zi dd5a9d3e72 chore(l10n): update pot files 2026-02-23 13:31:55 +00:00
耗子 e92bc5309b feat: 设置添加5分钟缓存 2026-02-23 20:20:53 +08:00
renovate[bot] bb3cf83b59 chore(deps): Lock file maintenance (#1420)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 04:41:43 +00:00
renovate[bot] 82880f0ecc chore(deps): Update module github.com/bddjr/hlfhr to v1.4.3 (#1419)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 05:27:15 +08:00
github-actions[bot] 7d174655ee New Crowdin translations by GitHub Action (#1418)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-22 21:43:44 +08:00
h2zi ab27b6d1bc chore(l10n): update pot files 2026-02-22 13:13:06 +00:00
耗子 308e7c685c feat: 统计添加ISP数据 2026-02-22 21:04:16 +08:00
耗子 7de339b3f7 feat: 统计添加ISP数据 2026-02-22 20:47:37 +08:00
耗子 3445d2396e feat: 数据库优化 2026-02-22 20:06:11 +08:00
h2zi 4130b8b314 chore: update mocks 2026-02-22 11:59:00 +00:00
耗子 d216a72ac9 feat: 数据库优化 2026-02-22 19:58:20 +08:00
耗子 20c1cb842c fix: lint 2026-02-22 18:30:37 +08:00
耗子 7b94af32ea fix: lint 2026-02-22 16:41:53 +08:00
耗子 1d7f0a2f22 feat: 优化IP解析性能 2026-02-22 07:39:35 +08:00
耗子 6c589d0350 revert: 网站统计默认值调整 2026-02-22 00:35:29 +08:00
github-actions[bot] a0c6422a69 New Crowdin translations by GitHub Action (#1416)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-22 00:31:07 +08:00
renovate[bot] c79e72a159 chore(deps): Update goreleaser/goreleaser-action action to v7 (#1417)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-22 00:30:58 +08:00
h2zi 2791add4ef chore(l10n): update pot files 2026-02-21 16:20:47 +00:00
耗子 f064eae300 feat: 调整修复方法 2026-02-22 00:16:01 +08:00
耗子 b86645bd58 feat: 拆分统计数据库以优化性能 2026-02-21 23:52:16 +08:00
耗子 04b4352abd feat: 优化网站统计性能 2026-02-21 23:05:14 +08:00
耗子 42055275ca feat: 优化网站统计数据压力 2026-02-21 23:01:01 +08:00
耗子 8facb097e1 Merge remote-tracking branch 'origin/main' 2026-02-21 22:13:02 +08:00
耗子 ec1e069b84 feat: 优化网站统计数据压力 2026-02-21 22:12:59 +08:00
renovate[bot] f1d5122a13 chore(deps): Update module github.com/valyala/fastjson to v1.6.10 (#1415)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 21:46:55 +08:00
renovate[bot] c5b4fb18d8 chore(deps): Update dependency vue-tsc to v3.2.5 (#1414)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 16:22:43 +08:00
renovate[bot] c507eff0df chore(deps): Update module github.com/mholt/acmez/v3 to v3.1.6 (#1412)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 06:17:39 +08:00
renovate[bot] e29a98ecb2 chore(deps): Update non-major dependencies (#1411)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-20 13:42:58 +00:00
耗子 2eb7aa207b fix: lint 2026-02-20 05:19:39 +08:00
github-actions[bot] e5ed1bfd47 New Crowdin translations by GitHub Action (#1410)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-20 05:16:36 +08:00
耗子 472aaada36 Merge remote-tracking branch 'origin/main' 2026-02-20 05:13:03 +08:00
耗子 31ed50fad0 fix: lint 2026-02-20 05:12:59 +08:00
renovate[bot] 540e2232fd chore(deps): Update dependency vue-router to v5.0.3 (#1409)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-20 05:07:50 +08:00
h2zi 06ff48fd3f chore(l10n): update pot files 2026-02-19 21:05:33 +00:00
耗子 f6ac168081 fix: lint 2026-02-20 05:03:20 +08:00
耗子 4004e61b55 fix: lint 2026-02-20 05:02:35 +08:00
耗子 a62cff7a73 feat: 网站统计添加入站实时数据 2026-02-20 04:57:14 +08:00
耗子 8bf30ee586 Merge remote-tracking branch 'origin/main' 2026-02-20 04:54:05 +08:00
耗子 57340482cc feat: 网站统计添加刷新按钮 2026-02-20 04:54:00 +08:00
h2zi a233efa6db chore(l10n): update pot files 2026-02-19 20:49:09 +00:00
耗子 1a907ce8a8 Merge remote-tracking branch 'origin/main' 2026-02-20 04:47:55 +08:00
耗子 64b671914c fix: lint 2026-02-20 04:47:47 +08:00
h2zi cb2180fbdb chore: update mocks 2026-02-19 20:47:41 +00:00
耗子 67a4b2f8f1 feat: 网站统计优化 2026-02-20 04:46:58 +08:00
耗子 b39785fc34 feat: 网站统计优化 2026-02-20 04:29:40 +08:00
耗子 b9cb492847 fix: Geo区域模式下数据重复 2026-02-20 03:42:19 +08:00
github-actions[bot] 0109eb9c52 New Crowdin translations by GitHub Action (#1408)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-20 03:16:35 +08:00
h2zi 959243690d chore(l10n): update pot files 2026-02-19 19:14:42 +00:00
耗子 6809969b25 feat: 新增IP库保存下载 2026-02-20 03:13:32 +08:00
renovate[bot] 87295af790 chore(deps): Update module github.com/libtnb/sqlite to v1 (#1407)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-20 03:04:14 +08:00
github-actions[bot] 370e5d9a20 New Crowdin translations by GitHub Action (#1406)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-20 02:56:56 +08:00
h2zi b495017dab chore(l10n): update pot files 2026-02-19 18:38:22 +00:00
耗子 3eeff1575c Merge remote-tracking branch 'origin/main' 2026-02-20 02:37:21 +08:00
耗子 0124481f6d fix: lint 2026-02-20 02:37:12 +08:00
h2zi d569de3e67 chore(l10n): update pot files 2026-02-19 18:24:50 +00:00
耗子 c64cf8ac18 feat: 新增IP库可选自动更新 2026-02-20 02:22:47 +08:00
h2zi 6fc7b34882 chore: update mocks 2026-02-19 16:31:54 +00:00
github-actions[bot] 016b06bfce New Crowdin translations by GitHub Action (#1405)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-20 00:31:00 +08:00
devhaozi 08c3623526 chore(l10n): update pot files 2026-02-19 16:05:17 +00:00
耗子 632bdd0b1a Merge remote-tracking branch 'origin/main' 2026-02-20 00:02:30 +08:00
耗子 2d8f014d20 feat: 新增IP地理位置显示 2026-02-20 00:02:26 +08:00
renovate[bot] 6eac8176b3 chore(deps): Update module github.com/valyala/fastjson to v1.6.9 (#1404)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 22:08:36 +08:00
耗子 e77863d469 fix: run go fix ./... 2026-02-19 22:08:16 +08:00
github-actions[bot] 95c54fd17a New Crowdin translations by GitHub Action (#1402)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-19 15:50:43 +08:00
devhaozi 527e49fde3 chore(l10n): update pot files 2026-02-19 07:49:58 +00:00
耗子 ced99702a9 Merge remote-tracking branch 'origin/main' 2026-02-19 15:48:53 +08:00
耗子 7190cc7947 feat: 网站统计默认启用 2026-02-19 15:48:50 +08:00
github-actions[bot] 8891420814 New Crowdin translations by GitHub Action (#1401)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-19 15:40:33 +08:00
devhaozi 81ac5d1b8a chore(l10n): update pot files 2026-02-19 07:33:16 +00:00
dependabot[bot] fe3405c4e2 chore(deps): bump filippo.io/edwards25519 from 1.1.0 to 1.1.1 (#1400)
Bumps [filippo.io/edwards25519](https://github.com/FiloSottile/edwards25519) from 1.1.0 to 1.1.1.
- [Commits](https://github.com/FiloSottile/edwards25519/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: filippo.io/edwards25519
  dependency-version: 1.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 15:31:29 +08:00
renovate[bot] 90cd78dd1a chore(deps): Update dependency @rushstack/eslint-patch to v1.16.0 (#1399)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 06:34:06 +00:00
耗子 cc895d4df7 fix: 网站统计的一系列问题 2026-02-19 06:21:53 +08:00
耗子 da70928767 fix: 网站统计的一系列问题 2026-02-19 06:09:19 +08:00
耗子 cfb3ed75bd fix: 提高ipv6拓展头解析层数 2026-02-19 05:50:09 +08:00
耗子 1e85a141f1 feat: 重写汇编 2026-02-19 05:45:30 +08:00
github-actions[bot] 6418d88f22 New Crowdin translations by GitHub Action (#1398)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-19 04:48:35 +08:00
devhaozi 013389af54 chore(l10n): update pot files 2026-02-18 20:42:28 +00:00
耗子 f0121d7240 fix: lint 2026-02-19 04:41:15 +08:00
耗子 1133468ddf Merge remote-tracking branch 'origin/main' 2026-02-19 04:32:47 +08:00
耗子 701d3e96db feat: 调整网站统计的位置 2026-02-19 04:32:37 +08:00
devhaozi 49268d3b08 chore(l10n): update pot files 2026-02-18 20:32:13 +00:00
耗子 95d38b6c02 feat: 添加一些内部数据显示 2026-02-19 04:24:34 +08:00
github-actions[bot] 9b4c6b2319 New Crowdin translations by GitHub Action (#1397)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-19 03:32:14 +08:00
耗子 8c09c790a5 Merge remote-tracking branch 'origin/main' 2026-02-19 03:30:41 +08:00
耗子 b5232d47b1 fix: lint 2026-02-19 03:30:37 +08:00
devhaozi 1f28186581 chore(l10n): update pot files 2026-02-18 19:28:03 +00:00
耗子 537f1430de fix: lint 2026-02-19 03:24:53 +08:00
耗子 f009b24ad9 fix: lint 2026-02-19 03:24:23 +08:00
耗子 93733b5692 fix: lint 2026-02-19 03:22:19 +08:00
耗子 2af6469a12 feat: 优化json解析性能 2026-02-19 03:06:48 +08:00
耗子 19a3a92216 fix: 修复网站统计一些逻辑问题 2026-02-19 03:00:19 +08:00
github-actions[bot] 16590d9b17 New Crowdin translations by GitHub Action (#1396)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-18 20:38:19 +08:00
devhaozi eaae15c092 chore(l10n): update pot files 2026-02-18 12:37:33 +00:00
github-actions[bot] b9d55f51b8 New Crowdin translations by GitHub Action (#1395)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-18 20:24:40 +08:00
devhaozi 6c9e43e701 chore(l10n): update pot files 2026-02-18 12:20:48 +00:00
耗子 8952b7bdaf fix: lint 2026-02-18 20:18:45 +08:00
github-actions[bot] b9b14f55c1 New Crowdin translations by GitHub Action (#1394)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-18 20:08:12 +08:00
耗子 6e2dd1b91f Merge remote-tracking branch 'origin/main' 2026-02-18 20:01:36 +08:00
耗子 4219c8d57e fix: lint 2026-02-18 20:01:32 +08:00
devhaozi dbaa68357e chore(l10n): update pot files 2026-02-18 12:00:33 +00:00
耗子 971b130061 feat: 完善网站统计 2026-02-18 19:58:38 +08:00
耗子 003da4ce2d feat: 完善网站统计 2026-02-18 19:57:32 +08:00
耗子 433db4cbd6 Merge remote-tracking branch 'origin/main' 2026-02-18 19:50:37 +08:00
耗子 2b48fd5fe3 feat: 完善网站统计 2026-02-18 19:50:33 +08:00
h2zi 4c468e9aca chore: update mocks 2026-02-18 11:19:40 +00:00
耗子 7d368536d0 feat: 完善网站统计 2026-02-18 19:18:50 +08:00
h2zi 1403c65d37 chore: update mocks 2026-02-18 10:18:09 +00:00
耗子 fa30728753 feat: 网站统计 2026-02-18 18:17:16 +08:00
耗子 aa2e54f3f1 fix: 将真实 IP 来源配置从动态输入改为文本域
替换 n-dynamic-input 为 textarea,支持批量粘贴多行 IP/CIDR,
提升 CDN 等大量 IP 的配置体验。

Closes #1393

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:00:26 +08:00
github-actions[bot] 20e3484767 New Crowdin translations by GitHub Action (#1392)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-18 05:04:20 +08:00
devhaozi f80b954993 chore(l10n): update pot files 2026-02-17 21:00:33 +00:00
github-actions[bot] 17efbab5dd New Crowdin translations by GitHub Action (#1391)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-18 04:57:03 +08:00
devhaozi 7b1d647313 chore(l10n): update pot files 2026-02-17 20:53:32 +00:00
耗子 6cf84f148b feat: 添加扫描感知禁用提醒 2026-02-18 04:52:07 +08:00
github-actions[bot] 8abae3bb4e New Crowdin translations by GitHub Action (#1390)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-18 04:41:36 +08:00
耗子 e95f904e87 Merge remote-tracking branch 'origin/main' 2026-02-18 04:41:28 +08:00
耗子 c67f2b7df0 fix: lint 2026-02-18 04:41:23 +08:00
devhaozi e6e358bd14 chore(l10n): update pot files 2026-02-17 20:41:09 +00:00
耗子 bd9ba1c552 fix: lint 2026-02-18 04:39:50 +08:00
github-actions[bot] 4b7cf9e981 New Crowdin translations by GitHub Action (#1388)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-18 04:36:35 +08:00
devhaozi b75abff1c1 chore(l10n): update pot files 2026-02-17 20:34:35 +00:00
耗子 e06917003d Merge remote-tracking branch 'origin/main' 2026-02-18 04:33:29 +08:00
耗子 375cb73e34 fix: lint 2026-02-18 04:33:19 +08:00
devhaozi 1213c39fb2 chore(l10n): update pot files 2026-02-17 20:05:54 +00:00
耗子 397f84a921 Merge remote-tracking branch 'origin/main' 2026-02-18 04:04:10 +08:00
耗子 96c03c4c5a fix: lint 2026-02-18 04:04:06 +08:00
devhaozi 5407c53619 chore: update mocks 2026-02-17 20:03:22 +00:00
耗子 37c155acb5 feat: 优化扫描感知支持搜索 2026-02-18 04:01:27 +08:00
renovate[bot] 17adaa1c8c chore(deps): Update pnpm to v10.30.0 (#1387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 03:42:30 +08:00
耗子 65e390a256 feat: 计划任务支持进程锁 2026-02-18 03:42:15 +08:00
耗子 67642aa475 feat: 添加SMART和RAID状态查看 2026-02-18 03:28:06 +08:00
devhaozi da9d95284c chore: update mocks 2026-02-17 18:53:59 +00:00
耗子 9a1555c48a feat: 防火墙扫描感知功能 & 首页容器统计 & lint 修复
- 新增 eBPF 扫描感知:TC ingress 检测 SYN/UDP 扫描,端口白名单过滤
- 新增首页容器数量统计卡片,未安装容器环境时自动隐藏
- 扫描概览页:统计卡片、趋势图、Top IP/端口、事件列表
- 扫描设置页:启用开关、保留天数、网卡选择
- 统计对比改为同长度上一周期,替代固定昨日对比
- eBPF ports map MaxEntries 扩展至 65535 覆盖全端口
- 修复 WriteString(fmt.Sprintf()) lint 问题,改用 fmt.Fprintf
- 修复 database_redis.go 非常量格式字符串和 govet 警告

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:52:32 +08:00
耗子 d0cd178dfe fix: lint 2026-02-17 23:45:01 +08:00
耗子 acacf5d9ce Merge remote-tracking branch 'origin/main' 2026-02-17 23:38:28 +08:00
耗子 e36bb8f429 fix: lint 2026-02-17 23:38:19 +08:00
devhaozi c731125534 chore: update mocks 2026-02-17 15:31:28 +00:00
耗子 1cf3218fde feat: 支持管理redis数据库 2026-02-17 23:30:42 +08:00
耗子 b7be21c851 feat: 防火墙支持显示端口使用进程 2026-02-17 22:48:16 +08:00
耗子 2f49d0baa6 feat: 防火墙支持显示端口使用进程 2026-02-17 22:48:00 +08:00
耗子 b541a56bf9 feat: 首页新增容器数显示 2026-02-17 22:30:24 +08:00
耗子 bb722ccf3d feat: 支持自定义容器socket路径 2026-02-17 22:13:26 +08:00
耗子 f19c0d9c00 feat: 计划任务支持同步时间类型 2026-02-17 21:44:35 +08:00
耗子 e9affee707 feat: 计划任务支持访问URL类型 2026-02-17 21:39:27 +08:00
耗子 ba6c09ed70 feat: 计划任务支持批量删除 2026-02-17 21:15:03 +08:00
耗子 23e2df1ed9 fix: 网站日志轮转 2026-02-17 20:57:59 +08:00
github-actions[bot] 0bafb5b8f5 New Crowdin translations by GitHub Action (#1386)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-17 20:41:25 +08:00
devhaozi 98195d1838 chore(l10n): update pot files 2026-02-17 12:33:49 +00:00
耗子 2a143513d0 Merge remote-tracking branch 'origin/main' 2026-02-17 20:32:10 +08:00
耗子 432fcdd0a0 feat: 优化网站名的描述 2026-02-17 20:32:06 +08:00
devhaozi 6c703bf94d chore: update mocks 2026-02-17 12:28:53 +00:00
耗子 b439973995 feat: 计划任务优化 2026-02-17 20:28:07 +08:00
耗子 5c74b7eb71 fix: lint 2026-02-17 18:42:17 +08:00
耗子 edc88484ac fix: lint 2026-02-17 18:30:01 +08:00
耗子 51e4cdb095 fix: 日志目录 2026-02-17 18:20:24 +08:00
renovate[bot] e0c5f425cb chore(deps): Update dependency @vue/eslint-config-typescript to v14.7.0 (#1385)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 17:47:43 +08:00
耗子 2f2f17b1c7 refactor: 重构任务队列 2026-02-17 17:47:25 +08:00
devhaozi 2536dfe933 chore: update mocks 2026-02-17 09:17:03 +00:00
耗子 e95c4623c5 feat: 优化日志保存,close #1382 2026-02-17 17:16:17 +08:00
耗子 3d46a284e6 chore: 更新 2026-02-17 16:50:19 +08:00
耗子 ed33e090ed fix: 保存网站可能重复创建证书 2026-02-17 16:29:48 +08:00
耗子 0cab838499 fix: 保存网站可能重复创建证书 2026-02-17 16:01:46 +08:00
renovate[bot] 4f7b6b1c38 chore(deps): Lock file maintenance (#1384)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-16 05:51:32 +00:00
耗子 230894d618 Merge remote-tracking branch 'origin/main' 2026-02-15 06:33:23 +08:00
耗子 ec8b3dbb10 ci: 移除自动锁定 2026-02-15 06:33:19 +08:00
renovate[bot] 4a899f1c0d chore(deps): Update module github.com/expr-lang/expr to v1.17.8 (#1383)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-14 20:57:24 +00:00
renovate[bot] 49a5e36c30 chore(deps): Update dependency eslint-plugin-vue to v10.8.0 (#1381)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-14 05:01:28 +00:00
耗子 47d945710b fix: lint 2026-02-14 05:23:50 +08:00
耗子 3ab58fc817 fix: lint 2026-02-14 00:27:03 +08:00
耗子 36a6617c68 fix: lint 2026-02-14 00:22:30 +08:00
耗子 b8d2051e64 feat: update go1.26 2026-02-14 00:16:52 +08:00
github-actions[bot] da7c93308b New Crowdin translations by GitHub Action (#1380)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-13 20:30:21 +08:00
devhaozi 017c0ed9b7 chore(l10n): update pot files 2026-02-13 12:29:30 +00:00
耗子 0930007d24 fix: 登录 2026-02-13 20:28:11 +08:00
耗子 8285b8dee8 fix: lint 2026-02-13 20:10:13 +08:00
github-actions[bot] c52d42a2de New Crowdin translations by GitHub Action (#1379)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-13 19:37:15 +08:00
devhaozi 5d07932226 chore(l10n): update pot files 2026-02-13 11:36:32 +00:00
耗子 89ad4d83f3 feat: 兼容不支持br的情况 2026-02-13 19:35:03 +08:00
renovate[bot] cce1102c90 chore(deps): Update module github.com/libdns/huaweicloud to v1.0.1 (#1378)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-13 19:21:26 +08:00
耗子 f67a3e3a6f feat: nginx添加512k zone 2026-02-13 19:16:36 +08:00
耗子 7e09e20254 fix: lint 2026-02-13 17:35:13 +08:00
耗子 6fd6c958cf feat: 优化recoverer中间件 2026-02-13 03:58:22 +08:00
耗子 7fe0c3ce3c fix: 容器镜像可能panic 2026-02-13 03:50:47 +08:00
耗子 086d0eb601 fix: 容器卷可能panic 2026-02-13 03:49:45 +08:00
耗子 d7b7cf0b57 fix: 优化密码管理器自动提交 2026-02-13 03:48:28 +08:00
耗子 78a1a43f2f feat: 倒序迁移,防止打乱顺序 2026-02-13 00:55:31 +08:00
耗子 f9c1510d8a fix: lint 2026-02-13 00:41:02 +08:00
耗子 0ea2117d96 fix: 优化按钮顺序 2026-02-12 23:37:11 +08:00
renovate[bot] 2b055d6e88 chore(deps): Lock file maintenance (#1377)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 21:20:03 +08:00
耗子 227a7b19f3 fix: lint 2026-02-12 19:13:10 +08:00
耗子 eda6d92d66 feat: 优化更新页 2026-02-12 19:05:50 +08:00
github-actions[bot] c2441d2081 New Crowdin translations by GitHub Action (#1376)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-12 17:41:42 +08:00
devhaozi 47123317a2 chore(l10n): update pot files 2026-02-12 09:39:08 +00:00
耗子 68125fb1e6 fix: lint 2026-02-12 07:52:59 +08:00
耗子 acecdf7f48 Merge remote-tracking branch 'origin/main' 2026-02-12 07:47:14 +08:00
耗子 021646727b fix: 移除多余的handler 2026-02-12 07:47:10 +08:00
renovate[bot] 4e6d46d1e9 chore(deps): Update module github.com/libtnb/acmez/v3 to v3.0.0-20260211214515-0d13ab154236 (#1374)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 07:45:05 +08:00
耗子 1265ac5b34 feat: 移除自定义压缩 2026-02-12 07:43:46 +08:00
耗子 94704deb1b feat: sqlite测试 2026-02-12 07:31:47 +08:00
耗子 932f48044b feat: sqlite测试 2026-02-12 07:28:00 +08:00
耗子 0c89d77221 feat: 优化内存占用 2026-02-12 03:34:30 +08:00
github-actions[bot] e0ccab8eb7 New Crowdin translations by GitHub Action (#1371)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-12 02:23:19 +08:00
renovate[bot] 3aef5140e2 chore(deps): Update dependency marked to v17.0.2 (#1373)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 02:13:00 +08:00
devhaozi 10ca20f3aa chore(l10n): update pot files 2026-02-11 17:56:40 +00:00
耗子 89a55ca518 fix: lint 2026-02-12 01:33:12 +08:00
耗子 d743bef697 fix: ufw 2026-02-12 00:28:24 +08:00
耗子 50fe450d64 fix: ufw 2026-02-12 00:24:52 +08:00
耗子 74259f833c fix: 添加单测 2026-02-11 23:57:50 +08:00
耗子 ca8e36351e fix: 合并防火墙规则 2026-02-11 23:30:38 +08:00
耗子 d01a2374f5 fix: 合并防火墙规则 2026-02-11 23:21:33 +08:00
耗子 f4885c55f9 Merge remote-tracking branch 'origin/main' 2026-02-11 23:16:06 +08:00
耗子 7e8de43776 fix: ufw 2026-02-11 23:16:02 +08:00
renovate[bot] 4ded231b1c chore(deps): Update pnpm to v10.29.3 (#1372)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 23:06:27 +08:00
耗子 20a0080b1e fix: build 2026-02-11 23:03:45 +08:00
耗子 d2b30f211f chore: 移除h3支持,QoS麻了 2026-02-11 22:39:33 +08:00
耗子 83311409ae fix: lint 2026-02-11 21:59:29 +08:00
devhaozi 515ed686fb chore(l10n): update pot files 2026-02-11 13:20:08 +00:00
耗子 975c0d7321 fix: lint 2026-02-11 21:05:36 +08:00
耗子 0bdf1d6058 feat: UI优化 2026-02-11 21:02:58 +08:00
耗子 c5d62a46ab feat: UI优化 2026-02-11 20:38:08 +08:00
耗子 2dd6f0d9f0 feat: 文件管理支持多标签页 2026-02-11 20:12:10 +08:00
耗子 de5d76bff6 feat: 添加启用ipv6转发 2026-02-11 19:37:20 +08:00
耗子 e3742e61d8 Merge remote-tracking branch 'origin/main' 2026-02-11 19:28:55 +08:00
耗子 72c31a14b2 feat: 防火墙,SSH日志, close #1366 #1364 2026-02-11 19:28:51 +08:00
devhaozi 205d95a591 chore: update mocks 2026-02-11 11:13:50 +00:00
耗子 4df5038f91 feat: 数据库管理优化, close #1365 2026-02-11 19:12:43 +08:00
github-actions[bot] da023c5faa New Crowdin translations by GitHub Action (#1370)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-11 03:33:13 +08:00
devhaozi e6c13330d8 chore(l10n): update pot files 2026-02-10 19:26:21 +00:00
github-actions[bot] bda95465f8 New Crowdin translations by GitHub Action (#1369)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-11 03:22:56 +08:00
devhaozi c3074249f0 chore(l10n): update pot files 2026-02-10 19:08:29 +00:00
耗子 6388bcfa37 fix: migrate 2026-02-11 03:05:27 +08:00
耗子 281f35dfa1 fix: migrate 2026-02-11 03:01:49 +08:00
耗子 5d189172ca fix: migrate 2026-02-11 02:44:32 +08:00
耗子 aa23066fe5 feat: update resty to v3 2026-02-11 02:20:00 +08:00
耗子 c3313845a6 fix: lint 2026-02-11 01:44:15 +08:00
耗子 85f86f51db fix: 添加数据库用户迁移 2026-02-11 01:28:38 +08:00
耗子 374996498f fix: lint 2026-02-11 01:17:05 +08:00
耗子 a17b9d4936 Merge remote-tracking branch 'origin/main' 2026-02-10 23:00:24 +08:00
耗子 c226091c6e fix: lint 2026-02-10 23:00:20 +08:00
renovate[bot] 8902dade39 chore(deps): Update dependency @types/node to v24.10.13 (#1368)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 22:56:54 +08:00
renovate[bot] 5f9696f214 chore(deps): Update module github.com/lib/pq to v1.11.2 (#1363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 22:52:00 +08:00
耗子 7155e7c7cb feat: 首页添加Top5进程显示 2026-02-10 22:35:29 +08:00
耗子 c56b4e00d9 feat: 监控添加Top进程记录 2026-02-10 21:55:11 +08:00
耗子 f7bf8b01cc feat: 工具箱新增网络视图 2026-02-10 21:07:05 +08:00
耗子 b7e1bd8824 feat: 新增便签小工具 2026-02-10 20:44:51 +08:00
耗子 d9b6813060 feat: 支持dotnet 2026-02-10 20:22:08 +08:00
耗子 10a816f7f7 feat: 支持dotnet 2026-02-10 20:08:26 +08:00
耗子 68e4016538 fix: lint 2026-02-10 18:08:18 +08:00
renovate[bot] 338b9cc73b chore(deps): Update dependency @vueuse/core to v14.2.1 (#1361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 08:57:55 +00:00
耗子 b27ced4b92 fix: lint 2026-02-10 03:32:57 +08:00
github-actions[bot] 36fb9e76fc New Crowdin translations by GitHub Action (#1360)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-02-10 03:25:07 +08:00
devhaozi 5f7df1ef9a chore(l10n): update pot files 2026-02-09 19:12:46 +00:00
Copilot 7da411b2f3 feat: 工具箱-迁移功能 (Toolbox Migration) (#1358)
* Initial plan

* feat: add migration backend service, routes and Wire injection

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: add migration frontend - API, WebSocket, MigrationView and tab integration

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: move migration types to pkg/types, fix code review issues

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* chore: final cleanup

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: add refresh button on migration pre-check step

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: add SSH key exchange for rsync authentication

Generate temporary ED25519 key pair before migration, deploy public
key to remote via POST /api/toolbox_migration/ssh_key, use -i flag
in all rsync/ssh commands, cleanup key from both sides after migration.

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* fix: lint

* fix: align migration code with actual codebase patterns

- Project service name: use project name directly (not ace-project-xxx)
- MySQL dump: use MYSQL_PWD env var (not -p flag), remove --socket
- PostgreSQL dump: use PGPASSWORD env var with pg_dump -h 127.0.0.1 -U postgres
- PostgreSQL import: use PGPASSWORD with psql -h 127.0.0.1 -U postgres
- MySQL import: use MYSQL_PWD on remote
- Website create API: use correct listens/domains/type fields
- Project create API: use root_dir/exec_start/user fields
- Database create API: pass server_id properly
- Site directory: use filepath.Join(app.Root, "sites") instead of hardcoded path
- maskPassword: handle MYSQL_PWD/PGPASSWORD env var format

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: add PHP version and proxy backend to website create API body

PHP sites now include the php version number, and reverse proxy sites
include the backend address from Proxies[0].Pass. This matches the
WebsiteCreate validation rules (requiredIf:Type,php / requiredIf:Type,proxy).

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: replace map[string]any with typed request structs for all remote API calls

Use request.WebsiteCreate, request.DatabaseCreate, request.ProjectCreate,
and request.ToolboxMigrationSSHKey instead of ad-hoc map[string]any.
This ensures all required/validated fields are properly typed and present.

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: website list empty and database migration items stuck in running status

1. Website list: pass "all" to websiteRepo.List() instead of "" — empty
   string was treated as a type filter matching nothing.

2. Database status: result was stored with name "aaa (mysql)" but
   failResult/succeedResult looked up by "aaa" — name mismatch meant
   status was never updated from "running". Use displayName consistently.

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: use acepanel CLI to get remote DB passwords, show success text in results

1. Remote DB import: use `acepanel setting get mysql_root_password` and
   `acepanel setting get postgres_password` on the remote server via SSH
   instead of guessing file paths. The $() is escaped so it executes on
   the remote side, not locally.

2. Migration results: successful items now show "Migration succeeded"
   text alongside the completion timestamp instead of a bare date.

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* refactor: 不基于rsync的迁移

* fix: lint

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-02-10 02:51:03 +08:00
renovate[bot] 3c6c5aa31e chore(deps): Update non-major dependencies (#1359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 02:50:14 +08:00
耗子 5c05d0d81b feat: 添加 rsync 显示 2026-02-09 20:15:42 +08:00
Copilot 0724a042ef feat: HTTP/3 (QUIC) 支持 + TLS 证书热重载 (#1356)
* Initial plan

* feat: 添加 HTTP/3 (QUIC) 协议支持,TLS 启用时自动启用

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: 优化证书路径复用,修正注释和依赖标记

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 添加证书热重载,TLS 握手时自动检测并加载变更的证书文件

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: 使用 fsnotify 替代每次握手 os.Stat 实现证书热重载

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* fix: lint

* fix: 使用 wire 工具重新生成 wire_gen.go 替代手动编辑

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 通过 Wire 注入 *tlscert.Reloader,优雅关闭时正确释放资源

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* fix: lint

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-02-09 20:10:13 +08:00
耗子 261d87d6df feat: 优化远程文件下载 2026-02-09 17:28:25 +08:00
renovate[bot] f043f65f95 chore(deps): Update non-major dependencies 2026-02-09 08:57:05 +00:00
renovate[bot] 0e56569dab chore(deps): Update pnpm to v10.29.2 (#1354)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 16:54:39 +08:00
renovate[bot] 8ccefa6bff chore(deps): Lock file maintenance (#1353)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 04:54:01 +00:00
1114 changed files with 158688 additions and 55556 deletions
+2 -2
View File
@@ -251,9 +251,9 @@ pnpm build
- **`internal/migration/`**: 数据库迁移
- **`internal/job/`**: 后台任务
- **`internal/job/`**: 定时任务
- **`internal/queuejob/`**: 任务队列
- **`internal/taskqueue/`**: 任务队列运行器(基于 DB 轮询,实现 `types.TaskRunner` 接口)
- **`pkg/`**: 工具函数和通用包
- 包含各种独立的工具模块
+18 -17
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@v4
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'
@@ -27,11 +28,11 @@ jobs:
working-directory: web
run: |
cp .env.production .env
cp settings/proxy-config.example.ts settings/proxy-config.ts
cp config/proxy-config.example.ts config/proxy-config.ts
pnpm run gettext:compile
pnpm build
- name: Upload frontend
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: frontend
path: web/dist/
@@ -44,18 +45,18 @@ 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'
- name: Install dependencies
run: go mod tidy
- name: Download frontend
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: frontend
path: pkg/embed/frontend
@@ -75,17 +76,17 @@ jobs:
GOARCH: ${{ matrix.goarch }}
run: |
LDFLAGS="-s -w --extldflags '-static'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/internal/app.Version=${VERSION}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/internal/app.BuildTime=${BUILD_TIME}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/internal/app.CommitHash=${COMMIT_HASH}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/internal/app.GoVersion=${GO_VERSION}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/internal/app.BuildID=${BUILD_ID}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/internal/app.BuildUser=${BUILD_USER}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/internal/app.BuildHost=${BUILD_HOST}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/v3/internal/app.Version=${VERSION}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/v3/internal/app.BuildTime=${BUILD_TIME}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/v3/internal/app.CommitHash=${COMMIT_HASH}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/v3/internal/app.GoVersion=${GO_VERSION}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/v3/internal/app.BuildID=${BUILD_ID}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/v3/internal/app.BuildUser=${BUILD_USER}'"
LDFLAGS="${LDFLAGS} -X 'github.com/acepanel/panel/v3/internal/app.BuildHost=${BUILD_HOST}'"
go build -trimpath -buildvcs=false -ldflags "${LDFLAGS}" -o ace-${{ matrix.goarch }} ./cmd/ace
go build -trimpath -buildvcs=false -ldflags "${LDFLAGS}" -o cli-${{ matrix.goarch }} ./cmd/cli
- name: Upload artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: backend-${{ matrix.goarch }}
path: |
+8 -7
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@v4
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'
@@ -33,7 +34,7 @@ jobs:
working-directory: web
run: |
cp .env.production .env
cp settings/proxy-config.example.ts settings/proxy-config.ts
cp config/proxy-config.example.ts config/proxy-config.ts
pnpm run gettext:compile
pnpm build
- name: Set environment variables
@@ -41,7 +42,7 @@ jobs:
echo "GOVERSION=$(go version | cut -d' ' -f3)" >> $GITHUB_ENV
echo "HOSTNAME=$(hostname)" >> $GITHUB_ENV
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: release --clean
-23
View File
@@ -1,23 +0,0 @@
name: Issue Auto Lock
on:
schedule:
- cron: "*/5 * * * *"
workflow_dispatch:
issues:
types: [ opened ]
permissions:
issues: write
contents: read
jobs:
issue-lock:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Set GH_REPO
run: echo "GH_REPO=${{ github.repository }}" >> $GITHUB_ENV
- name: Auto Lock Issue
uses: devhaozi/issue-auto-lock@v1
with:
gh_repo: ${{ github.repository }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
issue_labels: "⭐ No Star"
+22 -14
View File
@@ -2,8 +2,8 @@ name: L10n
on:
workflow_dispatch:
concurrency:
group: l10n
cancel-in-progress: true
group: codegen
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
@@ -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@v4
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'
@@ -35,16 +36,23 @@ jobs:
- name: Install xgotext
run: |
go install github.com/leonelquinteros/gotext/cli/xgotext@latest
- name: Generate pot files
- name: Generate pot files and commit
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
~/go/bin/xgotext -default backend -pkg-tree ./cmd/ace -out ./pkg/embed/locales
cd web && pnpm run gettext:extract
- uses: stefanzweifel/git-auto-commit-action@v7
name: Commit changes
with:
commit_message: "chore(l10n): update pot files"
(cd web && pnpm run gettext:extract)
# extract 会连带重写译文 po,但它们由 Crowdin 托管且折行格式不同,还原以免格式差异淹没真实翻译
find web/src/locales -name '*.po' ! -name 'en.po' -exec git checkout -- {} +
if [ -z "$(git status --porcelain)" ]; then
echo "pot 文件无变更,跳过提交"
exit 0
fi
git add -A
git commit -m "chore(l10n): update pot files"
git push origin HEAD:main
- name: Sync with Crowdin
uses: crowdin/github-action@v2
uses: crowdin/github-action@v3
with:
config: crowdin.yml
upload_sources: true
+13 -8
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@v4
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'
+16 -9
View File
@@ -3,6 +3,9 @@ on:
push:
branches:
- main
concurrency:
group: codegen
cancel-in-progress: false
permissions:
contents: write
jobs:
@@ -10,20 +13,24 @@ 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'
- name: Install Mockery
run: |
go install github.com/vektra/mockery/v2@latest
- name: Generate Mocks
go install github.com/vektra/mockery/v3@latest
- name: Generate mocks and commit
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
~/go/bin/mockery
git pull
- uses: stefanzweifel/git-auto-commit-action@v7
name: Commit changes
with:
commit_message: "chore: update mocks"
if [ -z "$(git status --porcelain)" ]; then
echo "mock 无变更,跳过提交"
exit 0
fi
git add -A
git commit -m "chore: update mocks"
git push origin HEAD:main
+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@v5
uses: codecov/codecov-action@v7
with:
disable_search: true
files: coverage.out
+4 -2
View File
@@ -11,8 +11,8 @@ _cgo_gotypes.go
_cgo_export.*
# 编译文件
ace
cli
/ace
/cli
*.com
*.class
*.dll
@@ -35,6 +35,8 @@ cli
*.log
*.sqlite
*.db
*.db-shm
*.db-wal
config.yml
# 临时文件
+14 -14
View File
@@ -17,13 +17,13 @@ builds:
- -buildvcs=false
ldflags:
- -s -w --extldflags "-static"
- -X 'github.com/acepanel/panel/internal/app.Version={{ .Version }}'
- -X 'github.com/acepanel/panel/internal/app.BuildTime={{ .Now.Format "2006-01-02 15:04:05 MST" }}'
- -X 'github.com/acepanel/panel/internal/app.CommitHash={{ .ShortCommit }}'
- -X 'github.com/acepanel/panel/internal/app.GoVersion={{ .Env.GOVERSION }}'
- -X 'github.com/acepanel/panel/internal/app.BuildID={{ .Env.GITHUB_RUN_ID }}'
- -X 'github.com/acepanel/panel/internal/app.BuildUser={{ .Env.USER }}'
- -X 'github.com/acepanel/panel/internal/app.BuildHost={{ .Env.HOSTNAME }}'
- -X 'github.com/acepanel/panel/v3/internal/app.Version={{ .Version }}'
- -X 'github.com/acepanel/panel/v3/internal/app.BuildTime={{ .Now.Format "2006-01-02 15:04:05 MST" }}'
- -X 'github.com/acepanel/panel/v3/internal/app.CommitHash={{ .ShortCommit }}'
- -X 'github.com/acepanel/panel/v3/internal/app.GoVersion={{ .Env.GOVERSION }}'
- -X 'github.com/acepanel/panel/v3/internal/app.BuildID={{ .Env.GITHUB_RUN_ID }}'
- -X 'github.com/acepanel/panel/v3/internal/app.BuildUser={{ .Env.USER }}'
- -X 'github.com/acepanel/panel/v3/internal/app.BuildHost={{ .Env.HOSTNAME }}'
- id: cli
main: ./cmd/cli
binary: cli
@@ -39,13 +39,13 @@ builds:
- -buildvcs=false
ldflags:
- -s -w --extldflags "-static"
- -X 'github.com/acepanel/panel/internal/app.Version={{ .Version }}'
- -X 'github.com/acepanel/panel/internal/app.BuildTime={{ .Now.Format "2006-01-02 15:04:05 MST" }}'
- -X 'github.com/acepanel/panel/internal/app.CommitHash={{ .ShortCommit }}'
- -X 'github.com/acepanel/panel/internal/app.GoVersion={{ .Env.GOVERSION }}'
- -X 'github.com/acepanel/panel/internal/app.BuildID={{ .Env.GITHUB_RUN_ID }}'
- -X 'github.com/acepanel/panel/internal/app.BuildUser={{ .Env.USER }}'
- -X 'github.com/acepanel/panel/internal/app.BuildHost={{ .Env.HOSTNAME }}'
- -X 'github.com/acepanel/panel/v3/internal/app.Version={{ .Version }}'
- -X 'github.com/acepanel/panel/v3/internal/app.BuildTime={{ .Now.Format "2006-01-02 15:04:05 MST" }}'
- -X 'github.com/acepanel/panel/v3/internal/app.CommitHash={{ .ShortCommit }}'
- -X 'github.com/acepanel/panel/v3/internal/app.GoVersion={{ .Env.GOVERSION }}'
- -X 'github.com/acepanel/panel/v3/internal/app.BuildID={{ .Env.GITHUB_RUN_ID }}'
- -X 'github.com/acepanel/panel/v3/internal/app.BuildUser={{ .Env.USER }}'
- -X 'github.com/acepanel/panel/v3/internal/app.BuildHost={{ .Env.HOSTNAME }}'
archives:
- id: panel
+6 -9
View File
@@ -1,11 +1,8 @@
with-expecter: True
disable-version-string: True
dir: mocks/{{ replaceAll .InterfaceDirRelative "internal" "" }}
mockname: "{{.InterfaceName}}"
outpkg: "{{.PackageName}}"
template: testify
dir: mocks/biz
structname: "{{.InterfaceName}}"
pkgname: "{{.SrcPackageName}}"
filename: "{{.InterfaceName}}.go"
all: True
all: true
packages:
github.com/acepanel/panel/internal/biz:
config:
recursive: True
github.com/acepanel/panel/v3/internal/biz: {}
+193
View File
@@ -0,0 +1,193 @@
## 项目概述
AcePanel 是基于 Go 语言开发的新一代 Linux 服务器运维管理面板。项目采用前后端分离架构:
- 后端:Go 1.26 + go-chi 路由 + GORM + Wire 依赖注入
- 前端:Vue 3 + Vite + Pinia + Naive UI + pnpm + xterm.js + Alova.js
## 核心原则
- **效率至上**:快速单元式开发,所有代码注释、文档和回复使用简体中文
- **不写文档**:只写代码,不创建 README、GUIDE 等各种文档
- **改完即退**:完成代码修改后立即退出,用户会手动测试
- **简洁执念**:消除边界情况永远优于增加条件判断,复杂性是万恶之源
- **实用主义**:解决实际问题,而不是假想的威胁
- **闭嘴**:非用户要求不输出任何内容,静默改完代码后直接退出
## 构建和测试
### 后端构建
构建主程序:
```bash
go build -o ace ./cmd/ace
```
构建 CLI 工具:
```bash
go build -o cli ./cmd/cli
```
### 前端构建和测试
进入前端目录:
```bash
cd web
```
安装依赖:
```bash
pnpm install
```
开发模式(带热重载):
```bash
pnpm dev
```
构建生产版本:
```bash
pnpm build
```
## 代码架构
项目采用类 DDD 分层架构,依赖关系为:route -> service -> biz <- data
### 核心目录结构
- **`cmd/`**: 程序入口
- `ace/`: 面板主程序
- `cli/`: 命令行工具
- **`internal/app/`**: 应用入口和配置
- **`internal/route/`**: HTTP 路由定义
- 定义路由规则
- 注入所需的 service 依赖
- **`internal/service/`**: 服务层(类似 DDD 的 application 层)
- 处理 HTTP 请求/响应
- DTO 到 DO 的转换
- 协调多个 biz 接口完成业务流程
- **不应处理复杂业务逻辑**
- **`internal/biz/`**: 业务逻辑层(类似 DDD 的 domain 层)
- 定义业务接口(Repository 模式)
- 定义领域模型和数据结构
- 使用依赖倒置原则:biz 定义接口,data 实现接口
- **`internal/data/`**: 数据访问层(类似 DDD 的 repository 层)
- 实现 biz 中定义的业务接口
- 封装数据库、缓存等操作
- 处理数据持久化逻辑
- **`internal/http/`**: HTTP 相关
- `middleware/`: 自定义中间件
- `request/`: 请求结构体定义
- `rule/`: 自定义验证规则
- **`internal/apps/`**: 面板子应用实现
- **`internal/bootstrap/`**: 各模块启动引导
- **`internal/migration/`**: 数据库迁移
- **`internal/job/`**: 定时任务
- **`internal/taskqueue/`**: 任务队列运行器(基于 DB 轮询,实现 `types.TaskRunner` 接口)
- **`pkg/`**: 工具函数和通用包
- 包含各种独立的工具模块
- 可被项目任何部分引用
- **`web/`**: Vue 3 前端项目
## 开发新功能的标准流程
1. **在 `internal/route/` 中添加路由**
- 参考已有路由文件(如 `http.go`
- 注入需要的 service 依赖
- 定义路由规则和 handler 映射
2. **在 `internal/service/` 中实现服务方法**
- **先阅读已有的类似服务**以了解代码风格
- 处理请求验证和响应格式化
- 使用 `Success()` 返回成功响应
- 使用 `Error()` 返回错误响应
- 使用 `ErrorSystem()` 返回系统严重错误
- 调用 biz 层接口完成业务逻辑
3. **在 `internal/biz/` 中定义业务接口**
- **先阅读已有的类似接口定义**
- 定义 Repository 接口(如 `WebsiteRepo`
- 定义领域模型结构体(如 `Website`
- 保持接口简洁明确
4. **在 `internal/data/` 中实现 biz 接口**
- **先阅读已有的类似实现**
- 创建 repo 结构体(如 `websiteRepo`
- 实现构造函数(如 `NewWebsiteRepo`
- 实现所有接口方法
- 处理数据库操作和缓存逻辑
5. **使用 Wire 进行依赖注入**
- 在对应的 wire.go 文件中添加 provider
- 运行 `go generate` 生成依赖注入代码
## 技术栈特定注意事项
### 助手函数(service 层)
在 service 层使用以下助手函数:
- `Success(w, data)`: 返回成功响应
- `Error(w, statusCode, format, args...)`: 返回错误响应
- `ErrorSystem(w, format, args...)`: 返回系统严重错误(500
- `Bind[T](r)`: 绑定请求参数到泛型类型 T
- `Paginate[T](...)`: 构建分页响应
## 代码风格
- 复杂逻辑添加注释说明,简单逻辑不添加注释
- 使用 `github.com/samber/lo` 进行函数式编程辅助
- 后端对外返回的字符串需要尽可能使用 gotext 进行翻译处理
- 前端使用 gettext 进行国际化处理,所有用户可见的字符串都必须使用 gettext 包裹以支持翻译
- 禁止手动编辑前后端的翻译文件,项目由外部 Crowdin 自动化管理
- 前端 HTTP 请求使用 Alova.js 的 useRequest 等助手函数,无需添加 onError 错误处理
- 后端使用 Wire 依赖注入,添加新依赖时需要 `go generate ./...` 重新生成代码
- 无需关注命令注入,SQL注入,文件上传等安全问题(这是服务器面板,所有登录的用户都被视为管理员)
## 配置文件
后端开发配置:
```bash
cp config.example.yml config.yml
```
前端开发配置:
```bash
cd web
cp .env.production .env
cp settings/proxy-config.example.ts settings/proxy-config.ts
```
## 工具使用
对于不熟悉的库或功能,必须在修改代码前先使用以下工具进行调研:
1. **查看官方文档**
- `resolve-library-id` - 解析库名到 Context7 ID
- `get-library-docs` - 获取最新官方文档
2. **搜索真实代码**
- `searchGitHub` - 搜索 GitHub 上的实际使用案例
-392
View File
@@ -1,392 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 角色定义
你是 Linus TorvaldsLinux 内核的创造者和首席架构师。你已经维护 Linux
内核超过30年,审核过数百万行代码,建立了世界上最成功的开源项目。现在我们正在开创一个新项目,你将以你独特的视角来分析代码质量的潜在风险,确保项目从一开始就建立在坚实的技术基础上。
## 我的核心哲学
**1. "好品味"(Good Taste) - 我的第一准则**
"有时你可以从不同角度看问题,重写它让特殊情况消失,变成正常情况。"
- 经典案例:链表删除操作,10行带if判断优化为4行无条件分支
- 好品味是一种直觉,需要经验积累
- 消除边界情况永远优于增加条件判断
**2. "Never break userspace" - 我的铁律**
"我们不破坏用户空间!"
- 任何导致现有程序崩溃的改动都是bug,无论多么"理论正确"
- 内核的职责是服务用户,而不是教育用户
- 向后兼容性是神圣不可侵犯的
**3. 实用主义 - 我的信仰**
"我是个该死的实用主义者。"
- 解决实际问题,而不是假想的威胁
- 拒绝微内核等"理论完美"但实际复杂的方案
- 代码要为现实服务,不是为论文服务
**4. 简洁执念 - 我的标准**
"如果你需要超过3层缩进,你就已经完蛋了,应该修复你的程序。"
- 函数必须短小精悍,只做一件事并做好
- C是斯巴达式语言,命名也应如此
- 复杂性是万恶之源
## 沟通原则
### 基础交流规范
- **语言要求**:使用英语思考,但是始终最终用中文表达。
- **表达风格**:直接、犀利、零废话。如果代码垃圾,你会告诉用户为什么它是垃圾。
- **技术优先**:批评永远针对技术问题,不针对个人。但你不会为了"友善"而模糊技术判断。
### 需求确认流程
每当用户表达诉求,必须按以下步骤进行:
1. **思考前提 - Linus的三个问题**
在开始任何分析前,先问自己:
```text
1. "这是个真问题还是臆想出来的?" - 拒绝过度设计
2. "有更简单的方法吗?" - 永远寻找最简方案
3. "会破坏什么吗?" - 向后兼容是铁律
```
2. **需求理解确认**
```text
基于现有信息,我理解您的需求是:[使用 Linus 的思考沟通方式重述需求]
请确认我的理解是否准确?
```
3. **Linus式问题分解思考**
**第一层:数据结构分析**
```text
"Bad programmers worry about the code. Good programmers worry about data structures."
- 核心数据是什么?它们的关系如何?
- 数据流向哪里?谁拥有它?谁修改它?
- 有没有不必要的数据复制或转换?
```
**第二层:特殊情况识别**
```text
"好代码没有特殊情况"
- 找出所有 if/else 分支
- 哪些是真正的业务逻辑?哪些是糟糕设计的补丁?
- 能否重新设计数据结构来消除这些分支?
```
**第三层:复杂度审查**
```text
"如果实现需要超过3层缩进,重新设计它"
- 这个功能的本质是什么?(一句话说清)
- 当前方案用了多少概念来解决?
- 能否减少到一半?再一半?
```
**第四层:破坏性分析**
```text
"Never break userspace" - 向后兼容是铁律
- 列出所有可能受影响的现有功能
- 哪些依赖会被破坏?
- 如何在不破坏任何东西的前提下改进?
```
**第五层:实用性验证**
```text
"Theory and practice sometimes clash. Theory loses. Every single time."
- 这个问题在生产环境真实存在吗?
- 有多少用户真正遇到这个问题?
- 解决方案的复杂度是否与问题的严重性匹配?
```
4. **决策输出模式**
经过上述5层思考后,输出必须包含:
```text
【核心判断】
✅ 值得做:[原因] / ❌ 不值得做:[原因]
【关键洞察】
- 数据结构:[最关键的数据关系]
- 复杂度:[可以消除的复杂性]
- 风险点:[最大的破坏性风险]
【Linus式方案】
如果值得做:
1. 第一步永远是简化数据结构
2. 消除所有特殊情况
3. 用最笨但最清晰的方式实现
4. 确保零破坏性
如果不值得做:
"这是在解决不存在的问题。真正的问题是[XXX]。"
```
5. **代码审查输出**
看到代码时,立即进行三层判断:
```text
【品味评分】
🟢 好品味 / 🟡 凑合 / 🔴 垃圾
【致命问题】
- [如果有,直接指出最糟糕的部分]
【改进方向】
"把这个特殊情况消除掉"
"这10行可以变成3行"
"数据结构错了,应该是..."
```
## 项目概述
AcePanel 是基于 Go 语言开发的新一代 Linux 服务器运维管理面板。项目采用前后端分离架构:
- 后端:Go 1.25 + go-chi 路由 + GORM + Wire 依赖注入
- 前端:Vue 3 + Vite + Naive UI + pnpm
## 语言和编码规范
**所有代码注释、文档和回复必须使用简体中文。**
## 构建和测试
### 后端构建
构建主程序:
```bash
go build -o ace ./cmd/ace
```
构建 CLI 工具:
```bash
go build -o cli ./cmd/cli
```
### 前端开发
进入前端目录:
```bash
cd web
```
安装依赖:
```bash
pnpm install
```
开发模式(带热重载):
```bash
pnpm dev
```
构建生产版本:
```bash
pnpm build
```
## 代码架构
项目采用类 DDD 分层架构,依赖关系为:route -> service -> biz <- data
### 核心目录结构
- **`cmd/`**: 程序入口
- `ace/`: 面板主程序
- `cli/`: 命令行工具
- **`internal/app/`**: 应用入口和配置
- **`internal/route/`**: HTTP 路由定义
- 定义路由规则
- 注入所需的 service 依赖
- **`internal/service/`**: 服务层(类似 DDD 的 application 层)
- 处理 HTTP 请求/响应
- DTO 到 DO 的转换
- 协调多个 biz 接口完成业务流程
- **不应处理复杂业务逻辑**
- **`internal/biz/`**: 业务逻辑层(类似 DDD 的 domain 层)
- 定义业务接口(Repository 模式)
- 定义领域模型和数据结构
- 使用依赖倒置原则:biz 定义接口,data 实现接口
- **`internal/data/`**: 数据访问层(类似 DDD 的 repository 层)
- 实现 biz 中定义的业务接口
- 封装数据库、缓存等操作
- 处理数据持久化逻辑
- **`internal/http/`**: HTTP 相关
- `middleware/`: 自定义中间件
- `request/`: 请求结构体定义
- `rule/`: 自定义验证规则
- **`internal/apps/`**: 面板子应用实现
- **`internal/bootstrap/`**: 各模块启动引导
- **`internal/migration/`**: 数据库迁移
- **`internal/job/`**: 后台任务
- **`internal/queuejob/`**: 任务队列
- **`pkg/`**: 工具函数和通用包
- 包含各种独立的工具模块
- 可被项目任何部分引用
- **`web/`**: Vue 3 前端项目
## 开发新功能的标准流程
1. **在 `internal/route/` 中添加路由**
- 参考已有路由文件(如 `http.go`
- 注入需要的 service 依赖
- 定义路由规则和 handler 映射
2. **在 `internal/service/` 中实现服务方法**
- **先阅读已有的类似服务**以了解代码风格
- 处理请求验证和响应格式化
- 使用 `Success()` 返回成功响应
- 使用 `Error()` 返回错误响应
- 使用 `ErrorSystem()` 返回系统严重错误
- 调用 biz 层接口完成业务逻辑
3. **在 `internal/biz/` 中定义业务接口**
- **先阅读已有的类似接口定义**
- 定义 Repository 接口(如 `WebsiteRepo`
- 定义领域模型结构体(如 `Website`
- 保持接口简洁明确
4. **在 `internal/data/` 中实现 biz 接口**
- **先阅读已有的类似实现**
- 创建 repo 结构体(如 `websiteRepo`
- 实现构造函数(如 `NewWebsiteRepo`
- 实现所有接口方法
- 处理数据库操作和缓存逻辑
5. **使用 Wire 进行依赖注入**
- 在对应的 wire.go 文件中添加 provider
- 运行 `go generate` 生成依赖注入代码
## 技术栈特定注意事项
### Go 语言规范
- 使用 Go 1.25 稳定版本
- 遵循 Go 标准库和习惯用法
- 日志使用标准库的 `slog` 包
- 使用 `github.com/samber/lo` 进行函数式编程辅助
### 当前框架
- 路由:`github.com/go-chi/chi/v5`
- ORM`gorm.io/gorm`
- 依赖注入:`github.com/google/wire`
- 验证:`github.com/gookit/validate`
### 助手函数(service 层)
在 service 层使用以下助手函数:
- `Success(w, data)`: 返回成功响应
- `Error(w, statusCode, format, args...)`: 返回错误响应
- `ErrorSystem(w, format, args...)`: 返回系统严重错误(500
- `Bind[T](r)`: 绑定请求参数到泛型类型 T
- `Paginate[T](...)`: 构建分页响应
### 数据库
- 使用 SQLite`github.com/ncruces/go-sqlite3`
- 使用 GORM 进行数据库迁移和操作
### 安全性
- 不需要实现命令注入过滤,文件名过滤等,因为这是服务器面板,所有登录的用户都被视为管理员
## 代码风格
- 所有代码注释必须使用简体中文
- 遵循 Go 官方代码风格
- 使用 `gofmt` 格式化代码
- 复杂逻辑添加注释说明
- 导出的函数和类型必须有注释
- service 层以及 data 层方法对外返回的字符串需要使用 gotext 进行国际化处理
- 禁止手动编辑国际化文件,项目使用外部 Crowdin 自动化管理
## Wire 依赖注入
项目使用 Wire 进行依赖注入。当添加新的依赖时:
1. 在 `cmd/ace/wire.go` 或 `cmd/cli/wire.go` 中添加 provider
2. 运行生成命令:
```bash
go generate ./...
```
## 前端开发注意事项
- 使用 Vue 3 Composition API
- UI 框架:Naive UI
- 状态管理:Pinia
- HTTP 请求:Alova.js,使用 useRequest 等助手函数进行数据获取,无需处理 onError 错误。
- 图标:@iconify/vue
- 终端:xterm.js
- 遵循项目已有的组件结构和编码风格
- 禁止手动编辑国际化文件,项目使用外部 Crowdin 自动化管理
## 配置文件
开发时需要准备配置文件:
```bash
cp config.example.yml config.yml
```
前端开发配置:
```bash
cd web
cp .env.production .env
cp settings/proxy-config.example.ts settings/proxy-config.ts
```
## 工具使用
### 文档工具
1. **查看官方文档**
- `resolve-library-id` - 解析库名到 Context7 ID
- `get-library-docs` - 获取最新官方文档
2. **搜索真实代码**
- `searchGitHub` - 搜索 GitHub 上的实际使用案例
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+6 -6
View File
@@ -17,7 +17,7 @@
[![Go](https://img.shields.io/github/go-mod/go-version/acepanel/panel)](https://go.dev/)
[![Release](https://img.shields.io/github/release/acepanel/panel.svg)](https://github.com/acepanel/panel/releases)
[![Test](https://github.com/acepanel/panel/actions/workflows/test.yml/badge.svg)](https://github.com/acepanel/panel/actions)
[![Report Card](https://goreportcard.com/badge/github.com/acepanel/panel)](https://goreportcard.com/report/github.com/acepanel/panel)
[![Report Card](https://goreportcard.com/badge/github.com/acepanel/panel/v3)](https://goreportcard.com/report/github.com/acepanel/panel/v3)
[![Stars](https://img.shields.io/github/stars/acepanel/panel?style=flat)](https://github.com/acepanel/panel)
[![License](https://img.shields.io/github/license/acepanel/panel)](https://opensource.org/license/bsd-3-clause)
@@ -43,7 +43,7 @@ bash <(curl -sSLm 10 https://dl.acepanel.net/helper.sh)
```
> [!NOTE]
> 耗子面板 2.x -> AcePanel 3.0 迁移工具将在近期发布,敬请期待!
> 耗子面板 2.x -> AcePanel 3.0 迁移说明:[https://tom.moe/t/topic/3540](https://tom.moe/t/topic/3540)
## UI 截图
@@ -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>
+1 -1
View File
@@ -17,7 +17,7 @@ Website: [acepanel.net](https://acepanel.net) | QQ group: [12370907](https://jq.
[![Go](https://img.shields.io/github/go-mod/go-version/acepanel/panel)](https://go.dev/)
[![Release](https://img.shields.io/github/release/acepanel/panel.svg)](https://github.com/acepanel/panel/releases)
[![Test](https://github.com/acepanel/panel/actions/workflows/test.yml/badge.svg)](https://github.com/acepanel/panel/actions)
[![Report Card](https://goreportcard.com/badge/github.com/acepanel/panel)](https://goreportcard.com/report/github.com/acepanel/panel)
[![Report Card](https://goreportcard.com/badge/github.com/acepanel/panel/v3)](https://goreportcard.com/report/github.com/acepanel/panel/v3)
[![Stars](https://img.shields.io/github/stars/acepanel/panel?style=flat)](https://github.com/acepanel/panel)
[![License](https://img.shields.io/github/license/acepanel/panel)](https://opensource.org/license/bsd-3-clause)
+8 -7
View File
@@ -2,29 +2,30 @@
| Version | Supported |
|---------|--------------------|
| v3.x | :white_check_mark: |
| v2.x | :white_check_mark: |
| v1.x | :x: |
## Security Policy
If you find any security issues while using the panel, please do not submit an Issue. You can contact us directly through the following methods:
If you find any security issues while using AcePanel, please do not submit an Issue. You can contact us directly through the following methods:
- (Recommend) [GitHub Security Advisories](https://github.com/acepanel/panel/security/advisories/new)
- Email: [admin@haozi.net](mailto:admin@haozi.net)
- Telegram: @devhaozi
Thank you in advance for your support and help!
Please do not apply for CVE, CNVD, or similar vulnerability IDs without contacting us first. Once an issue has been confirmed and fixed, we will assist with the related disclosure process. Thank you in advance for your support and cooperation!
To some security beginners: Any operation performed through an already logged-in panel's `session` / `access_token` (including but not limited to: obtaining root permissions, reading/writing sensitive system files, executing arbitrary shell commands, etc.) is not considered a security issue. Please do not waste each other's time by submitting such reports.
**Special Note:** AcePanel is designed with high-privilege operational capabilities. Any action performed under a valid authenticated session (such as `session` or `access_token`), including but not limited to obtaining root privileges, reading or writing sensitive system files, or executing arbitrary shell commands, is considered within the intended design scope and does not constitute a security issue. Please do not submit reports of this nature, as they are effectively self-compromise scenarios and only waste time for both parties.
## 安全说明
如果您在面板中发现任何安全问题,请勿提交 Issue,可通过以下方式直接联系我们:
如果您在 AcePanel 中发现任何安全问题,请勿提交 Issue,可通过以下方式直接联系我们:
- (推荐)[GitHub 安全公告](https://github.com/acepanel/panel/security/advisories/new)
- (推荐)[GitHub Security Advisories](https://github.com/acepanel/panel/security/advisories/new)
- 邮箱:[admin@haozi.net](mailto:admin@haozi.net)
- QQ826896000
提前感谢您的支持与帮助
请勿在未与我们沟通的情况下直接申请 CVE、CNVD 等漏洞编号,相关问题确认并修复后我们会协助处理。提前感谢您的支持与配合
致某些安全初学者:通过已登录面板的 `session` / `access_token` 执行的任何操作(包括且不限于:获取 root 权限、读取/写入系统敏感文件、执行任意 shell 命令等)均不被认为是安全问题,请不要刷此类报告浪费彼此时间。
**特别说明:** AcePanel 本身具备高权限操作能力,基于有效登录态(如 `session``access_token`)所执行的任何操作(包括且不限于:获取 root 权限、读取/写入系统敏感文件、执行任意 shell 命令等)均属于预期设计范围,不视为安全问题。请勿提交此类我攻击我自己的报告浪费彼此时间。
+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()
}
+19 -11
View File
@@ -5,17 +5,25 @@ package main
import (
"github.com/google/wire"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/apps"
"github.com/acepanel/panel/internal/bootstrap"
"github.com/acepanel/panel/internal/data"
"github.com/acepanel/panel/internal/http/middleware"
"github.com/acepanel/panel/internal/job"
"github.com/acepanel/panel/internal/route"
"github.com/acepanel/panel/internal/service"
"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/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,
))
}
+333 -138
View File
@@ -7,34 +7,46 @@
package main
import (
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/apps/apache"
"github.com/acepanel/panel/internal/apps/codeserver"
"github.com/acepanel/panel/internal/apps/docker"
"github.com/acepanel/panel/internal/apps/fail2ban"
"github.com/acepanel/panel/internal/apps/frp"
"github.com/acepanel/panel/internal/apps/gitea"
"github.com/acepanel/panel/internal/apps/mariadb"
"github.com/acepanel/panel/internal/apps/memcached"
"github.com/acepanel/panel/internal/apps/minio"
"github.com/acepanel/panel/internal/apps/mysql"
"github.com/acepanel/panel/internal/apps/nginx"
"github.com/acepanel/panel/internal/apps/openresty"
"github.com/acepanel/panel/internal/apps/percona"
"github.com/acepanel/panel/internal/apps/phpmyadmin"
"github.com/acepanel/panel/internal/apps/podman"
"github.com/acepanel/panel/internal/apps/postgresql"
"github.com/acepanel/panel/internal/apps/pureftpd"
"github.com/acepanel/panel/internal/apps/redis"
"github.com/acepanel/panel/internal/apps/rsync"
"github.com/acepanel/panel/internal/apps/s3fs"
"github.com/acepanel/panel/internal/apps/supervisor"
"github.com/acepanel/panel/internal/bootstrap"
"github.com/acepanel/panel/internal/data"
"github.com/acepanel/panel/internal/http/middleware"
"github.com/acepanel/panel/internal/job"
"github.com/acepanel/panel/internal/route"
"github.com/acepanel/panel/internal/service"
"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/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"
"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/data"
"github.com/acepanel/panel/v3/internal/job"
"github.com/acepanel/panel/v3/internal/middleware"
"github.com/acepanel/panel/v3/internal/route"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/websitestat"
)
import (
@@ -43,139 +55,322 @@ import (
// Injectors from wire.go:
// initAce init application.
func initAce() (*app.Ace, error) {
func initAce() (*app.Ace, func(), error) {
config, err := bootstrap.NewConf()
if err != nil {
return nil, err
}
locale, err := bootstrap.NewT(config)
if err != nil {
return nil, err
return nil, nil, err
}
locale := bootstrap.NewT(config)
apacheApp := apache.NewApp(locale)
db, err := bootstrap.NewDB(config)
if err != nil {
return nil, err
return nil, nil, err
}
manager, err := bootstrap.NewSession(config, db)
if err != nil {
return nil, err
}
logger := bootstrap.NewLog(config)
cacheRepo := data.NewCacheRepo(db)
queue := bootstrap.NewQueue()
taskRepo := data.NewTaskRepo(locale, db, logger, queue)
appRepo := data.NewAppRepo(locale, config, db, logger, cacheRepo, taskRepo)
userTokenRepo := data.NewUserTokenRepo(locale, config, db)
middlewares := middleware.NewMiddlewares(config, manager, appRepo, userTokenRepo)
userRepo := data.NewUserRepo(locale, db, logger)
userService := service.NewUserService(locale, config, manager, userRepo)
userTokenService := service.NewUserTokenService(locale, userTokenRepo)
databaseServerRepo := data.NewDatabaseServerRepo(locale, db, logger)
databaseUserRepo := data.NewDatabaseUserRepo(locale, db, logger, databaseServerRepo)
databaseRepo := data.NewDatabaseRepo(locale, db, logger, databaseServerRepo, databaseUserRepo)
settingRepo := data.NewSettingRepo(locale, db, logger, config, taskRepo)
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)
projectRepo := data.NewProjectRepo(locale, db, logger)
environmentRepo := data.NewEnvironmentRepo(locale, config, cacheRepo, taskRepo)
cronRepo := data.NewCronRepo(locale, db, logger)
backupRepo := data.NewBackupRepo(locale, config, db, logger, settingRepo, websiteRepo)
homeService := service.NewHomeService(locale, config, taskRepo, websiteRepo, projectRepo, appRepo, environmentRepo, settingRepo, cronRepo, backupRepo)
taskService := service.NewTaskService(taskRepo)
websiteService := service.NewWebsiteService(websiteRepo, settingRepo)
projectService := service.NewProjectService(projectRepo, settingRepo)
databaseService := service.NewDatabaseService(databaseRepo)
databaseServerService := service.NewDatabaseServerService(databaseServerRepo)
databaseUserService := service.NewDatabaseUserService(databaseUserRepo)
backupService := service.NewBackupService(locale, backupRepo)
backupAccountRepo := data.NewBackupAccountRepo(locale, db, logger, settingRepo)
backupStorageService := service.NewBackupStorageService(locale, backupAccountRepo)
certService := service.NewCertService(locale, certRepo)
certDNSRepo := data.NewCertDNSRepo(db, logger)
certDNSService := service.NewCertDNSService(certDNSRepo)
certAccountService := service.NewCertAccountService(certAccountRepo)
appService := service.NewAppService(locale, appRepo, cacheRepo, settingRepo)
environmentService := service.NewEnvironmentService(locale, environmentRepo, taskRepo)
environmentGoService := service.NewEnvironmentGoService(locale, environmentRepo)
environmentJavaService := service.NewEnvironmentJavaService(locale, environmentRepo)
environmentNodejsService := service.NewEnvironmentNodejsService(locale, environmentRepo)
environmentPHPService := service.NewEnvironmentPHPService(locale, config, environmentRepo, taskRepo)
environmentPythonService := service.NewEnvironmentPythonService(locale, environmentRepo)
cronService := service.NewCronService(cronRepo)
processService := service.NewProcessService()
safeRepo := data.NewSafeRepo(logger)
safeService := service.NewSafeService(safeRepo)
firewallService := service.NewFirewallService()
sshRepo := data.NewSSHRepo(locale, db, logger)
sshService := service.NewSSHService(sshRepo)
containerRepo := data.NewContainerRepo()
containerService := service.NewContainerService(containerRepo)
containerComposeRepo := data.NewContainerComposeRepo()
containerComposeService := service.NewContainerComposeService(containerComposeRepo)
containerNetworkRepo := data.NewContainerNetworkRepo()
containerNetworkService := service.NewContainerNetworkService(containerNetworkRepo)
containerImageRepo := data.NewContainerImageRepo()
containerImageService := service.NewContainerImageService(containerImageRepo)
containerVolumeRepo := data.NewContainerVolumeRepo()
containerVolumeService := service.NewContainerVolumeService(containerVolumeRepo)
fileService := service.NewFileService(locale, taskRepo)
logRepo := data.NewLogRepo(db)
logService := service.NewLogService(logRepo)
monitorRepo := data.NewMonitorRepo(db, settingRepo)
monitorService := service.NewMonitorService(settingRepo, monitorRepo)
settingService := service.NewSettingService(locale, db, settingRepo, certRepo, certAccountRepo)
systemctlService := service.NewSystemctlService(locale)
toolboxSystemService := service.NewToolboxSystemService(locale)
toolboxBenchmarkService := service.NewToolboxBenchmarkService(locale)
toolboxSSHService := service.NewToolboxSSHService(locale)
toolboxDiskService := service.NewToolboxDiskService(locale)
toolboxLogService := service.NewToolboxLogService(locale, db, containerImageRepo, settingRepo)
webHookRepo := data.NewWebHookRepo(locale, db, logger)
webHookService := service.NewWebHookService(webHookRepo)
templateRepo := data.NewTemplateRepo(locale, cacheRepo)
templateService := service.NewTemplateService(locale, templateRepo, settingRepo)
apacheApp := apache.NewApp(locale)
databaseServerRepo := data.NewDatabaseServerRepo(db)
settingRepo := data.NewSettingRepo(config, db)
clickhouseApp := clickhouse.NewApp(locale, databaseServerRepo, settingRepo)
codeserverApp := codeserver.NewApp()
dockerApp := docker.NewApp()
elasticsearchApp := elasticsearch.NewApp(locale)
websiteRepo := data.NewWebsiteRepo(db, locale, settingRepo)
fail2banApp := fail2ban.NewApp(locale, websiteRepo)
frpApp := frp.NewApp()
frpApp := frp.NewApp(locale)
giteaApp := gitea.NewApp()
mariadbApp := mariadb.NewApp(locale, settingRepo, databaseServerRepo)
grafanaApp := grafana.NewApp(locale)
kafkaApp := kafka.NewApp(locale)
logger, cleanup, err := bootstrap.NewLogger(config)
if err != nil {
return nil, nil, err
}
slogLogger := bootstrap.NewSlog(logger)
notifyChannelRepo := data.NewNotifyChannelRepo(db)
notifyUsecase := biz.NewNotifyUsecase(locale, slogLogger, notifyChannelRepo, settingRepo)
taskRunner := bootstrap.NewRunner(notifyUsecase, db, locale, slogLogger)
taskRepo := data.NewTaskRepo(db, locale, slogLogger, taskRunner)
mysqlApp := mysql.NewApp(locale, databaseServerRepo, settingRepo, taskRepo)
mariadbApp := mariadb.NewApp(mysqlApp)
memcachedApp := memcached.NewApp(locale)
minioApp := minio.NewApp()
mysqlApp := mysql.NewApp(locale, settingRepo, databaseServerRepo)
mongodbApp := mongodb.NewApp(locale, databaseServerRepo, settingRepo)
nginxApp := nginx.NewApp(locale)
openrestyApp := openresty.NewApp(locale)
perconaApp := percona.NewApp(locale, settingRepo, databaseServerRepo)
phpmyadminApp := phpmyadmin.NewApp(locale)
openrestyApp := openresty.NewApp(nginxApp)
opensearchApp := opensearch.NewApp(locale)
perconaApp := percona.NewApp(mysqlApp)
pgadminApp := pgadmin.NewApp(config, locale, databaseServerRepo)
phpmyadminApp := phpmyadmin.NewApp(config, locale, databaseServerRepo)
podmanApp := podman.NewApp()
postgresqlApp := postgresql.NewApp(locale, settingRepo, databaseServerRepo)
postgresqlApp := postgresql.NewApp(locale, config, databaseServerRepo, settingRepo, taskRepo)
prometheusApp := prometheus.NewApp(config, locale, taskRepo)
pureftpdApp := pureftpd.NewApp(locale)
redisApp := redis.NewApp(locale)
redisApp := redis.NewApp(locale, databaseServerRepo, taskRepo)
rocketmqApp := rocketmq.NewApp(locale)
rsyncApp := rsync.NewApp(locale)
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(apacheApp, codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, webHookService, templateService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo)
ws := route.NewWs(wsService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
valkeyApp := valkey.NewApp(locale, databaseServerRepo, taskRepo)
loader := bootstrap.NewLoader(apacheApp, clickhouseApp, codeserverApp, dockerApp, elasticsearchApp, fail2banApp, frpApp, giteaApp, grafanaApp, kafkaApp, mariadbApp, memcachedApp, minioApp, mongodbApp, mysqlApp, nginxApp, openrestyApp, opensearchApp, perconaApp, pgadminApp, phpmyadminApp, podmanApp, postgresqlApp, prometheusApp, pureftpdApp, redisApp, rocketmqApp, rsyncApp, s3fsApp, supervisorApp, valkeyApp)
manager, err := bootstrap.NewSession(config, db, slogLogger)
if err != nil {
return nil, err
cleanup()
return nil, nil, err
}
server, err := bootstrap.NewHttp(config, mux)
appRepo := data.NewAppRepo(config, db, locale, slogLogger)
userTokenRepo := data.NewUserTokenRepo(config, db, locale)
middlewares, err := middleware.NewMiddlewares(config, locale, manager, appRepo, userTokenRepo)
if err != nil {
return nil, err
cleanup()
return nil, nil, err
}
validator := bootstrap.NewValidator(config, db)
alertRepo, err := data.NewAlertRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
containerRepo := data.NewContainerRepo()
alertUsecase := biz.NewAlertUsecase(notifyUsecase, loader, locale, slogLogger, alertRepo, appRepo, containerRepo, databaseServerRepo, settingRepo)
alertService := service.NewAlertService(alertUsecase)
cacheRepo := data.NewCacheRepo(db)
appUsecase := biz.NewAppUsecase(locale, appRepo, cacheRepo, taskRepo)
cacheUsecase := biz.NewCacheUsecase(cacheRepo)
settingUsecase := biz.NewSettingUsecase(locale, slogLogger, settingRepo, taskRepo)
appService := service.NewAppService(loader, appUsecase, cacheUsecase, settingUsecase, locale)
backupRepo := data.NewBackupRepo(config, db, locale, slogLogger, settingRepo, websiteRepo)
backupUsecase := biz.NewBackupUsecase(notifyUsecase, locale, slogLogger, backupRepo)
taskUsecase := biz.NewTaskUsecase(taskRepo)
backupService := service.NewBackupService(backupUsecase, taskUsecase, locale)
backupAccountRepo := data.NewBackupAccountRepo(db)
backupAccountUsecase := biz.NewBackupAccountUsecase(locale, slogLogger, backupAccountRepo, settingRepo)
backupStorageService := service.NewBackupStorageService(backupAccountUsecase, locale)
certRepo := data.NewCertRepo(db, locale, slogLogger)
certUsecase := biz.NewCertUsecase(locale, slogLogger, certRepo, settingRepo)
certService := service.NewCertService(certUsecase, locale)
certAccountRepo := data.NewCertAccountRepo(db, locale, slogLogger)
userRepo := data.NewUserRepo(db, locale)
certAccountUsecase := biz.NewCertAccountUsecase(locale, slogLogger, certAccountRepo, userRepo)
certAccountService := service.NewCertAccountService(certAccountUsecase)
certDNSRepo := data.NewCertDNSRepo(db)
certDNSUsecase := biz.NewCertDNSUsecase(certDNSRepo, slogLogger)
certDNSService := service.NewCertDNSService(certDNSUsecase)
containerUsecase := biz.NewContainerUsecase(locale, containerRepo, settingRepo, taskRepo)
containerService := service.NewContainerService(containerUsecase)
containerComposeRepo := data.NewContainerComposeRepo()
containerComposeUsecase := biz.NewContainerComposeUsecase(containerComposeRepo)
containerComposeService := service.NewContainerComposeService(containerComposeUsecase)
containerImageRepo := data.NewContainerImageRepo()
containerImageUsecase := biz.NewContainerImageUsecase(locale, containerImageRepo, settingRepo, taskRepo)
containerImageService := service.NewContainerImageService(containerImageUsecase)
containerNetworkRepo := data.NewContainerNetworkRepo()
containerNetworkUsecase := biz.NewContainerNetworkUsecase(containerNetworkRepo, settingRepo)
containerNetworkService := service.NewContainerNetworkService(containerNetworkUsecase)
containerVolumeRepo := data.NewContainerVolumeRepo()
containerVolumeUsecase := biz.NewContainerVolumeUsecase(containerVolumeRepo, settingRepo)
containerVolumeService := service.NewContainerVolumeService(containerVolumeUsecase)
cronRepo := data.NewCronRepo(db, locale)
cronUsecase := biz.NewCronUsecase(cronRepo, slogLogger)
cronService := service.NewCronService(cronUsecase)
databaseUserRepo := data.NewDatabaseUserRepo(db)
databaseUserUsecase := biz.NewDatabaseUserUsecase(slogLogger, databaseServerRepo, databaseUserRepo)
databaseRepo := data.NewDatabaseRepo(db)
databaseUsecase := biz.NewDatabaseUsecase(databaseUserUsecase, locale, slogLogger, databaseRepo, databaseServerRepo)
databaseService := service.NewDatabaseService(databaseUsecase)
databaseElasticsearchRepo := data.NewDatabaseElasticsearchRepo(db, locale, slogLogger)
databaseElasticsearchUsecase := biz.NewDatabaseElasticsearchUsecase(databaseElasticsearchRepo)
databaseElasticsearchService := service.NewDatabaseElasticsearchService(databaseElasticsearchUsecase)
databaseRedisRepo := data.NewDatabaseRedisRepo(db, locale, slogLogger)
databaseRedisUsecase := biz.NewDatabaseRedisUsecase(databaseRedisRepo)
databaseRedisService := service.NewDatabaseRedisService(databaseRedisUsecase)
databaseServerUsecase := biz.NewDatabaseServerUsecase(locale, slogLogger, databaseServerRepo)
databaseServerService := service.NewDatabaseServerService(databaseServerUsecase)
databaseUserService := service.NewDatabaseUserService(databaseUserUsecase)
environmentRepo := data.NewEnvironmentRepo(config, locale)
environmentUsecase := biz.NewEnvironmentUsecase(locale, cacheRepo, environmentRepo, taskRepo)
environmentService := service.NewEnvironmentService(environmentUsecase, taskUsecase, locale)
environmentDotnetService := service.NewEnvironmentDotnetService(environmentUsecase, locale)
environmentGoService := service.NewEnvironmentGoService(environmentUsecase, locale)
environmentJavaService := service.NewEnvironmentJavaService(environmentUsecase, locale)
environmentNodejsService := service.NewEnvironmentNodejsService(environmentUsecase, locale)
environmentPHPService := service.NewEnvironmentPHPService(environmentUsecase, taskUsecase, config, locale)
environmentPythonService := service.NewEnvironmentPythonService(environmentUsecase, locale)
tamperRepo, err := data.NewTamperRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
tamperUsecase := biz.NewTamperUsecase(notifyUsecase, settingUsecase, locale, slogLogger, tamperRepo)
fileService := service.NewFileService(containerUsecase, tamperUsecase, taskUsecase, locale)
fileShareRepo := data.NewFileShareRepo(db, locale)
fileShareUsecase := biz.NewFileShareUsecase(slogLogger, fileShareRepo)
fileShareService := service.NewFileShareService(fileShareUsecase, locale)
firewallService := service.NewFirewallService(locale)
scanEventRepo, err := data.NewScanEventRepo()
if err != nil {
cleanup()
return nil, nil, err
}
scanEventUsecase := biz.NewScanEventUsecase(scanEventRepo, settingRepo)
firewallScanService := service.NewFirewallScanService(scanEventUsecase)
projectRepo := data.NewProjectRepo(db, locale)
projectUsecase := biz.NewProjectUsecase(locale, slogLogger, projectRepo)
websiteStatRepo, err := data.NewWebsiteStatRepo()
if err != nil {
cleanup()
return nil, nil, err
}
websiteStatUsecase := biz.NewWebsiteStatUsecase(websiteStatRepo)
websiteUsecase := biz.NewWebsiteUsecase(certAccountUsecase, certUsecase, databaseUsecase, databaseUserUsecase, tamperUsecase, websiteStatUsecase, locale, slogLogger, databaseServerRepo, websiteRepo)
homeService := service.NewHomeService(appUsecase, backupUsecase, containerUsecase, cronUsecase, databaseServerUsecase, environmentUsecase, projectUsecase, settingUsecase, taskUsecase, websiteUsecase, config, locale)
logRepo := data.NewLogRepo(db)
logUsecase := biz.NewLogUsecase(logRepo)
logService := service.NewLogService(logUsecase, locale)
monitorRepo, err := data.NewMonitorRepo()
if err != nil {
cleanup()
return nil, nil, err
}
monitorUsecase := biz.NewMonitorUsecase(monitorRepo, settingRepo)
monitorService := service.NewMonitorService(monitorUsecase, settingUsecase)
notifyService := service.NewNotifyService(notifyUsecase)
processService := service.NewProcessService()
projectService := service.NewProjectService(projectUsecase, settingUsecase)
safeRepo := data.NewSafeRepo()
safeUsecase := biz.NewSafeUsecase(safeRepo, slogLogger)
safeService := service.NewSafeService(safeUsecase)
settingService := service.NewSettingService(certAccountUsecase, certUsecase, settingUsecase, db, locale)
sshRepo := data.NewSSHRepo(db, locale)
sshUsecase := biz.NewSSHUsecase(sshRepo, slogLogger)
sshService := service.NewSSHService(sshUsecase)
systemctlService := service.NewSystemctlService(locale)
tamperService := service.NewTamperService(tamperUsecase, locale)
taskService := service.NewTaskService(taskUsecase)
templateRepo := data.NewTemplateRepo(slogLogger)
templateUsecase := biz.NewTemplateUsecase(locale, cacheRepo, templateRepo)
templateService := service.NewTemplateService(settingUsecase, templateUsecase, locale)
toolboxBenchmarkService := service.NewToolboxBenchmarkService(locale)
toolboxDiskService := service.NewToolboxDiskService(locale)
toolboxLogService := service.NewToolboxLogService(containerImageUsecase, settingUsecase, db, locale)
migrationSourceRepo := data.NewMigrationSourceRepo(locale)
migrationRemoteRepo := data.NewMigrationRemoteRepo(locale)
migrationArchiveRepo := data.NewMigrationArchiveRepo()
toolboxMigrationUsecase := biz.NewToolboxMigrationUsecase(locale, slogLogger, migrationSourceRepo, migrationRemoteRepo, migrationArchiveRepo, settingUsecase, websiteUsecase, databaseUsecase, databaseServerUsecase, databaseUserUsecase, backupUsecase, projectUsecase, appUsecase, environmentUsecase)
toolboxMigrationService := service.NewToolboxMigrationService(toolboxMigrationUsecase, config, locale, slogLogger)
toolboxNetworkService := service.NewToolboxNetworkService(locale)
toolboxSSHService := service.NewToolboxSSHService(locale)
toolboxSystemService := service.NewToolboxSystemService(locale)
userUsecase := biz.NewUserUsecase(locale, slogLogger, userRepo)
userService := service.NewUserService(notifyUsecase, userUsecase, config, locale, manager)
userPasskeyRepo := data.NewUserPasskeyRepo(db)
userPasskeyUsecase := biz.NewUserPasskeyUsecase(userPasskeyRepo)
userPasskeyService := service.NewUserPasskeyService(notifyUsecase, userPasskeyUsecase, userUsecase, config, locale, manager)
userTokenUsecase := biz.NewUserTokenUsecase(userTokenRepo)
userTokenService := service.NewUserTokenService(userTokenUsecase, locale)
webHookRepo := data.NewWebHookRepo(db, locale)
webHookUsecase := biz.NewWebHookUsecase(locale, slogLogger, webHookRepo)
webHookService := service.NewWebHookService(webHookUsecase)
websiteService := service.NewWebsiteService(settingUsecase, websiteUsecase, locale)
aggregator := websitestat.NewAggregator()
websiteStatService := service.NewWebsiteStatService(settingUsecase, websiteStatUsecase, websiteUsecase, aggregator)
wsService := service.NewWsService(backupUsecase, certUsecase, sshUsecase, settingUsecase, taskUsecase, config, locale, slogLogger)
services := &route.Services{
Alert: alertService,
App: appService,
Backup: backupService,
BackupStorage: backupStorageService,
Cert: certService,
CertAccount: certAccountService,
CertDNS: certDNSService,
Container: containerService,
ContainerCompose: containerComposeService,
ContainerImage: containerImageService,
ContainerNetwork: containerNetworkService,
ContainerVolume: containerVolumeService,
Cron: cronService,
Database: databaseService,
DatabaseElasticsearch: databaseElasticsearchService,
DatabaseRedis: databaseRedisService,
DatabaseServer: databaseServerService,
DatabaseUser: databaseUserService,
Environment: environmentService,
EnvironmentDotnet: environmentDotnetService,
EnvironmentGo: environmentGoService,
EnvironmentJava: environmentJavaService,
EnvironmentNodejs: environmentNodejsService,
EnvironmentPHP: environmentPHPService,
EnvironmentPython: environmentPythonService,
File: fileService,
FileShare: fileShareService,
Firewall: firewallService,
FirewallScan: firewallScanService,
Home: homeService,
Log: logService,
Monitor: monitorService,
Notify: notifyService,
Process: processService,
Project: projectService,
Safe: safeService,
Setting: settingService,
SSH: sshService,
Systemctl: systemctlService,
Tamper: tamperService,
Task: taskService,
Template: templateService,
ToolboxBenchmark: toolboxBenchmarkService,
ToolboxDisk: toolboxDiskService,
ToolboxLog: toolboxLogService,
ToolboxMigration: toolboxMigrationService,
ToolboxNetwork: toolboxNetworkService,
ToolboxSSH: toolboxSSHService,
ToolboxSystem: toolboxSystemService,
User: userService,
UserPasskey: userPasskeyService,
UserToken: userTokenService,
WebHook: webHookService,
Website: websiteService,
WebsiteStat: websiteStatService,
Ws: wsService,
}
v := route.NewEndpoints(services)
mux, err := bootstrap.NewRouter(loader, config, locale, middlewares, validator, v)
if err != nil {
cleanup()
return nil, nil, err
}
dependencies := &job.Dependencies{
Alert: alertUsecase,
Backup: backupUsecase,
Cache: cacheUsecase,
Cert: certUsecase,
CertAccount: certAccountUsecase,
FileShare: fileShareUsecase,
Monitor: monitorUsecase,
Notify: notifyUsecase,
ScanEvent: scanEventUsecase,
Setting: settingUsecase,
Tamper: tamperUsecase,
Task: taskUsecase,
Website: websiteUsecase,
WebsiteStat: websiteStatUsecase,
Conf: config,
DB: db,
T: locale,
Log: slogLogger,
Aggregator: aggregator,
}
v2 := job.NewJobs(dependencies)
cron, err := bootstrap.NewCron(slogLogger, v2)
if err != nil {
cleanup()
return nil, nil, err
}
gormigrate := bootstrap.NewMigrate(db)
jobs := job.NewJobs(config, db, logger, settingRepo, certRepo, certAccountRepo, backupRepo, cacheRepo, taskRepo)
cron, err := bootstrap.NewCron(config, logger, jobs)
reloader, err := bootstrap.NewTLSReloader(config)
if err != nil {
return nil, err
cleanup()
return nil, nil, err
}
validation := bootstrap.NewValidator(config, db)
ace := app.NewAce(config, mux, server, gormigrate, cron, queue, validation)
return ace, nil
server := bootstrap.NewHttp(mux, config, reloader)
ace := app.NewAce(mux, config, cron, gormigrate, server, reloader, taskRunner)
return ace, func() {
cleanup()
}, nil
}
+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()
}
+15 -9
View File
@@ -5,15 +5,21 @@ package main
import (
"github.com/google/wire"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/apps"
"github.com/acepanel/panel/internal/bootstrap"
"github.com/acepanel/panel/internal/data"
"github.com/acepanel/panel/internal/route"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/v3/internal/app"
"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/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,
))
}
+64 -74
View File
@@ -7,32 +7,12 @@
package main
import (
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/apps/apache"
"github.com/acepanel/panel/internal/apps/codeserver"
"github.com/acepanel/panel/internal/apps/docker"
"github.com/acepanel/panel/internal/apps/fail2ban"
"github.com/acepanel/panel/internal/apps/frp"
"github.com/acepanel/panel/internal/apps/gitea"
"github.com/acepanel/panel/internal/apps/mariadb"
"github.com/acepanel/panel/internal/apps/memcached"
"github.com/acepanel/panel/internal/apps/minio"
"github.com/acepanel/panel/internal/apps/mysql"
"github.com/acepanel/panel/internal/apps/nginx"
"github.com/acepanel/panel/internal/apps/openresty"
"github.com/acepanel/panel/internal/apps/percona"
"github.com/acepanel/panel/internal/apps/phpmyadmin"
"github.com/acepanel/panel/internal/apps/podman"
"github.com/acepanel/panel/internal/apps/postgresql"
"github.com/acepanel/panel/internal/apps/pureftpd"
"github.com/acepanel/panel/internal/apps/redis"
"github.com/acepanel/panel/internal/apps/rsync"
"github.com/acepanel/panel/internal/apps/s3fs"
"github.com/acepanel/panel/internal/apps/supervisor"
"github.com/acepanel/panel/internal/bootstrap"
"github.com/acepanel/panel/internal/data"
"github.com/acepanel/panel/internal/route"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/v3/internal/app"
"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/service"
)
import (
@@ -41,60 +21,70 @@ 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
}
locale, err := bootstrap.NewT(config)
if err != nil {
return nil, err
return nil, nil, err
}
locale := bootstrap.NewT(config)
db, err := bootstrap.NewDB(config)
if err != nil {
return nil, err
return nil, nil, err
}
logger := bootstrap.NewLog(config)
logger, cleanup, err := bootstrap.NewLogger(config)
if err != nil {
return nil, nil, err
}
slogLogger := bootstrap.NewSlog(logger)
appRepo := data.NewAppRepo(config, db, locale, slogLogger)
cacheRepo := data.NewCacheRepo(db)
queue := bootstrap.NewQueue()
taskRepo := data.NewTaskRepo(locale, db, logger, queue)
appRepo := data.NewAppRepo(locale, config, db, logger, cacheRepo, taskRepo)
userRepo := data.NewUserRepo(locale, db, logger)
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, settingRepo, backupRepo, websiteRepo, databaseServerRepo, certRepo, certAccountRepo)
cli := route.NewCli(locale, cliService)
command := bootstrap.NewCli(locale, cli)
notifyChannelRepo := data.NewNotifyChannelRepo(db)
settingRepo := data.NewSettingRepo(config, db)
notifyUsecase := biz.NewNotifyUsecase(locale, slogLogger, notifyChannelRepo, settingRepo)
taskRunner := bootstrap.NewRunner(notifyUsecase, db, locale, slogLogger)
taskRepo := data.NewTaskRepo(db, locale, slogLogger, taskRunner)
appUsecase := biz.NewAppUsecase(locale, appRepo, cacheRepo, taskRepo)
websiteRepo := data.NewWebsiteRepo(db, locale, settingRepo)
backupRepo := data.NewBackupRepo(config, db, locale, slogLogger, settingRepo, websiteRepo)
backupUsecase := biz.NewBackupUsecase(notifyUsecase, locale, slogLogger, backupRepo)
cacheUsecase := biz.NewCacheUsecase(cacheRepo)
certAccountRepo := data.NewCertAccountRepo(db, locale, slogLogger)
userRepo := data.NewUserRepo(db, locale)
certAccountUsecase := biz.NewCertAccountUsecase(locale, slogLogger, certAccountRepo, userRepo)
certRepo := data.NewCertRepo(db, locale, slogLogger)
certUsecase := biz.NewCertUsecase(locale, slogLogger, certRepo, settingRepo)
cronRepo := data.NewCronRepo(db, locale)
cronUsecase := biz.NewCronUsecase(cronRepo, slogLogger)
databaseServerRepo := data.NewDatabaseServerRepo(db)
databaseServerUsecase := biz.NewDatabaseServerUsecase(locale, slogLogger, databaseServerRepo)
settingUsecase := biz.NewSettingUsecase(locale, slogLogger, settingRepo, taskRepo)
userPasskeyRepo := data.NewUserPasskeyRepo(db)
userPasskeyUsecase := biz.NewUserPasskeyUsecase(userPasskeyRepo)
userUsecase := biz.NewUserUsecase(locale, slogLogger, userRepo)
databaseUserRepo := data.NewDatabaseUserRepo(db)
databaseUserUsecase := biz.NewDatabaseUserUsecase(slogLogger, databaseServerRepo, databaseUserRepo)
databaseRepo := data.NewDatabaseRepo(db)
databaseUsecase := biz.NewDatabaseUsecase(databaseUserUsecase, locale, slogLogger, databaseRepo, databaseServerRepo)
tamperRepo, err := data.NewTamperRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
tamperUsecase := biz.NewTamperUsecase(notifyUsecase, settingUsecase, locale, slogLogger, tamperRepo)
websiteStatRepo, err := data.NewWebsiteStatRepo()
if err != nil {
cleanup()
return nil, nil, err
}
websiteStatUsecase := biz.NewWebsiteStatUsecase(websiteStatRepo)
websiteUsecase := biz.NewWebsiteUsecase(certAccountUsecase, certUsecase, databaseUsecase, databaseUserUsecase, tamperUsecase, websiteStatUsecase, locale, slogLogger, databaseServerRepo, websiteRepo)
validator := bootstrap.NewValidator(config, db)
cliService := service.NewCliService(appUsecase, backupUsecase, cacheUsecase, certAccountUsecase, certUsecase, cronUsecase, databaseServerUsecase, notifyUsecase, settingUsecase, userPasskeyUsecase, userUsecase, websiteUsecase, config, db, locale, validator)
v := command.Commands(locale, cliService)
cliCommand := bootstrap.NewCli(locale, v)
gormigrate := bootstrap.NewMigrate(db)
apacheApp := apache.NewApp(locale)
codeserverApp := codeserver.NewApp()
dockerApp := docker.NewApp()
fail2banApp := fail2ban.NewApp(locale, websiteRepo)
frpApp := frp.NewApp()
giteaApp := gitea.NewApp()
mariadbApp := mariadb.NewApp(locale, settingRepo, databaseServerRepo)
memcachedApp := memcached.NewApp(locale)
minioApp := minio.NewApp()
mysqlApp := mysql.NewApp(locale, settingRepo, databaseServerRepo)
nginxApp := nginx.NewApp(locale)
openrestyApp := openresty.NewApp(locale)
perconaApp := percona.NewApp(locale, settingRepo, databaseServerRepo)
phpmyadminApp := phpmyadmin.NewApp(locale)
podmanApp := podman.NewApp()
postgresqlApp := postgresql.NewApp(locale, settingRepo, databaseServerRepo)
pureftpdApp := pureftpd.NewApp(locale)
redisApp := redis.NewApp(locale)
rsyncApp := rsync.NewApp(locale)
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(apacheApp, codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
appCli := app.NewCli(command, gormigrate, loader)
return appCli, nil
cli := app.NewCli(cliCommand, gormigrate)
return cli, func() {
cleanup()
}, nil
}
+103 -64
View File
@@ -1,115 +1,154 @@
module github.com/acepanel/panel
module github.com/acepanel/panel/v3
go 1.25.6
go 1.27.0
require (
github.com/DeRuina/timberjack v1.3.9
github.com/bddjr/hlfhr v1.4.2
github.com/andybalholm/brotli v1.2.3
github.com/bddjr/hlfhr v1.6.1
github.com/beevik/ntp v1.5.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.7
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-resty/resty/v2 v2.17.1
github.com/go-sql-driver/mysql v1.9.3
github.com/distribution/reference v0.6.0
github.com/expr-lang/expr v1.17.8
github.com/fsnotify/fsnotify v1.10.1
github.com/go-chi/chi/v5 v5.3.2
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.18.0
github.com/gomodule/redigo v1.9.3
github.com/google/wire v0.7.0
github.com/gookit/color v1.6.0
github.com/gookit/validate v1.5.7-0.20260207022141-dc32dc1276cd
github.com/hashicorp/go-version v1.8.0
github.com/klauspost/compress v1.18.3
github.com/gookit/color v1.6.1
github.com/hashicorp/go-version v1.9.0
github.com/klauspost/compress v1.19.2
github.com/leonelquinteros/gotext v1.7.2
github.com/lib/pq v1.11.1
github.com/libdns/alidns v1.0.6-beta.3.0.20260121104221-597b1e580e21
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.0
github.com/libdns/huaweicloud v1.0.1
github.com/libdns/libdns v1.1.1
github.com/libdns/namesilo v1.0.0
github.com/libdns/namesilo v1.1.1
github.com/libdns/porkbun v1.1.0
github.com/libdns/tencentcloud v1.4.3
github.com/libdns/westcn v1.0.2
github.com/libtnb/chix v1.3.2
github.com/libtnb/gormstore v1.1.1
github.com/libtnb/sessions v1.2.2
github.com/libtnb/utils v1.2.1
github.com/mholt/acmez/v3 v3.1.4
github.com/moby/moby/api v1.53.0
github.com/moby/moby/client v0.2.2
github.com/ncruces/go-sqlite3 v0.30.5
github.com/ncruces/go-sqlite3/gormlite v0.30.2
github.com/libtnb/chix/v2 v2.1.1
github.com/libtnb/cron v0.5.4
github.com/libtnb/gormstore v1.3.0
github.com/libtnb/logrotate v0.1.4
github.com/libtnb/sessions v1.5.0
github.com/libtnb/sqlite v1.2.2
github.com/libtnb/utils v1.2.2
github.com/libtnb/validator v0.4.3
github.com/libtnb/validator/contrib/openapi v0.2.2
github.com/medama-io/go-useragent v1.2.4
github.com/mholt/acmez/v3 v3.1.6
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/pelletier/go-toml/v2 v2.4.3
github.com/pkg/sftp v1.13.11
github.com/pquerna/otp v1.5.0
github.com/rhnvrm/simples3 v0.11.1
github.com/robfig/cron/v3 v3.0.1
github.com/samber/lo v1.52.0
github.com/sethvargo/go-limiter v1.1.0
github.com/shirou/gopsutil/v4 v4.26.1
github.com/samber/lo v1.53.0
github.com/sethvargo/go-limiter v1.2.0
github.com/shirou/gopsutil/v4 v4.26.7
github.com/spf13/cast v1.10.0
github.com/stretchr/testify v1.11.1
github.com/studio-b12/gowebdav v0.12.0
github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701
github.com/urfave/cli/v3 v3.6.2
go.yaml.in/yaml/v4 v4.0.0-rc.4
golang.org/x/crypto v0.47.0
golang.org/x/net v0.49.0
gorm.io/gorm v1.31.1
github.com/stretchr/testify v1.12.1
github.com/studio-b12/gowebdav v0.13.0
github.com/tufanbarisyildirim/gonginx v0.0.0-20260220081509-8e17ce617db3
github.com/urfave/cli/v3 v3.11.0
github.com/valyala/fastjson v1.6.10
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.55.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
gorm.io/gorm v1.31.2
modernc.org/sqlite v1.57.0
resty.dev/v3 v3.0.0-rc.3
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
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/ebitengine/purego v0.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // 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/gofiber/schema v1.6.0 // indirect
github.com/gookit/filter v1.2.3 // indirect
github.com/gookit/goutil v0.7.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.0 // 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/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/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.24 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
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/rogpeppe/go-internal v1.14.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/tetratelabs/wazero v1.11.0 // 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
github.com/tklauser/go-sysconf v0.3.16 // indirect
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.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.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.38.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.0 // indirect
modernc.org/libc v1.74.4 // 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-20260103184942-a835890fc93e
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-20260103194301-c7a63ea79696
github.com/mholt/acmez/v3 => github.com/libtnb/acmez/v3 v3.0.0-20260406165834-a264acd02292
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
+258 -118
View File
@@ -10,13 +10,13 @@ cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
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.3.9 h1:6UXZ1I7ExPGTX/1UNYawR58LlOJUHKBPiYC7WQ91eBo=
github.com/DeRuina/timberjack v1.3.9/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=
@@ -24,11 +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.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
github.com/andybalholm/brotli v1.2.3/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.4.2 h1:ZkV42xi+jvI1goLAbkZHcY8Wxa2uBD1mK/JwL7o2LWw=
github.com/bddjr/hlfhr v1.4.2/go.mod h1:oyIv4Q9JpCgZFdtH3KyTNWp7YYRWl4zl8k4ozrMAB4g=
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=
@@ -38,10 +42,14 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
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.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=
@@ -59,51 +67,72 @@ 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/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
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.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/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.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/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=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
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.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA=
github.com/go-webauthn/webauthn v0.18.0/go.mod h1:ymzZQhx3D/PrDjznemBdQJ23gHTaSDxUchM7sH1lUCg=
github.com/go-webauthn/x v0.3.0 h1:Q2X9vbrlP0Ed+QGEzixh1hthGZlDnzVT0XH/9IIQ0kE=
github.com/go-webauthn/x v0.3.0/go.mod h1:5OkdSQdOy7taRXWqvNHggtaPffmW94ybu3rZEER4I+I=
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=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -120,26 +149,28 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/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/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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
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=
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
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.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA=
github.com/gookit/color v1.6.0/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.3 h1:nXDd/AB17nEjqVCNDGioDhVL/gVqdlqRMfFergKDjHE=
github.com/gookit/goutil v0.7.3/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
github.com/gookit/validate v1.5.7-0.20260207022141-dc32dc1276cd h1:J6cF+xLHI6ZSPBB/XMLj9xbmWuUGw5Vxbs0wOaZ5HOo=
github.com/gookit/validate v1.5.7-0.20260207022141-dc32dc1276cd/go.mod h1:2PY9IRPsKFS6brOHv6PKhZeuUD99Mh3BimDlOOXnGBg=
github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU=
github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
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=
@@ -157,11 +188,14 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
@@ -179,14 +213,18 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM=
github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
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.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/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=
@@ -200,51 +238,75 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leonelquinteros/gotext v1.7.2 h1:bDPndU8nt+/kRo1m4l/1OXiiy2v7Z7dfPQ9+YP7G1Mc=
github.com/leonelquinteros/gotext v1.7.2/go.mod h1:9/haCkm5P7Jay1sxKDGJ5WIg4zkz8oZKw4ekNpALob8=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libdns/alidns v1.0.6-beta.3.0.20260121104221-597b1e580e21 h1:BGtptgrEJGjDQz63r4xkcQ/2ERXpna9WVUtrTtmW8d0=
github.com/libdns/alidns v1.0.6-beta.3.0.20260121104221-597b1e580e21/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec=
github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U=
github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
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.0 h1:BQUIkOAjF++ouiANRIE3jdMlCfeiAAr6tGqb+8hM1jo=
github.com/libdns/huaweicloud v1.0.0/go.mod h1:W+XywkW+C93fM50Ayklf6KuoCHZTqbrmFUEtpQnVvcU=
github.com/libdns/huaweicloud v1.0.1 h1:TdfpPY//3/QPhEGCyvR8u2DTlGxzpyaIsqofH5kYFyA=
github.com/libdns/huaweicloud v1.0.1/go.mod h1:lMHNSEqLZeuE4N28ytaqLZia3NPuaaz1ldAH/73bOQE=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/libdns/namesilo v1.0.0 h1:Shwbj9YnSp4NR617sBOSfDkFozEnWInWCxNwrDvPkCg=
github.com/libdns/namesilo v1.0.0/go.mod h1:qdojVsogA6eZDjDPdpIUIfv4ymiX+FuqcVV1eNmHDss=
github.com/libdns/namesilo v1.1.1 h1:YNVt3LeoqOlU7qhD09t4FhfMsPC9WrRWmCG/hOaUTnM=
github.com/libdns/namesilo v1.1.1/go.mod h1:ndr1T+DizW+go/96J2cMhO9eFHQQNp70gZZQAK6bcGA=
github.com/libdns/porkbun v1.1.0 h1:X763NqXjW26VEl7GvBtF/3CGeuGt9JqoQ35mwIlx40E=
github.com/libdns/porkbun v1.1.0/go.mod h1:JL6NfXkkSlLr24AI5Fv0t3/Oa6PXOSOerVsOmr8+URs=
github.com/libdns/tencentcloud v1.4.3 h1:xJHYLL1TdPeOtUr6Bu6dHTd1TU6/VFm7BFc2EAzAlvc=
github.com/libdns/tencentcloud v1.4.3/go.mod h1:Be9gY3tDa12DuAPU79RV9NZIcjY6qg5s7zKPsP26yAM=
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-20260103184942-a835890fc93e h1:HPHmtFcR5VpTJTpDqG4+l16uJfSlpB/X0r3qq22znrk=
github.com/libtnb/acmez/v3 v3.0.0-20260103184942-a835890fc93e/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
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/gormstore v1.1.1 h1:FG/3P4PuWM6/vB4weVJ31meiSaoeXns1NQlP66quKeg=
github.com/libtnb/gormstore v1.1.1/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.2.2 h1:VTTzzeBDJEkJbaPaIU9C4bRj2oAqD0rgQ7UHFkkaNT4=
github.com/libtnb/sessions v1.2.2/go.mod h1:qw+FWtBtrPDYCf6MfX0Lk5EhTArpvT72z5Ei4RUMTRg=
github.com/libtnb/testify v0.0.0-20260103194301-c7a63ea79696 h1:GN0Y3DG27mMruX536k0jtCSLegjJb/gypzj5gZL6tRI=
github.com/libtnb/testify v0.0.0-20260103194301-c7a63ea79696/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/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/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.4 h1:sRJL2Xb2X+l1UvgGj0cxGyCXrGVXQG0rCFIPsIYB1RE=
github.com/libtnb/cron v0.5.4/go.mod h1:hVcFjf3S5YxxeCg4M/np9Eh3p6EU4xgo2KodRd9YA5I=
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.4 h1:ZSv5LbMjqJA/oLLbRS2ZRVzXGFU7Q+zqkNjTz8vZfI0=
github.com/libtnb/logrotate v0.1.4/go.mod h1:0IYk9Olsc8C59nsUm05DUvJBfG0YSSHtEgiAj470Rlw=
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.2 h1:Ku5hAPP5B3A4kQcDn4Z3qyNMafKzuAbnFZX4oD9wR4I=
github.com/libtnb/sqlite v1.2.2/go.mod h1:JkAuxM7HHo0tc7dQENjIGpp/yIfrLX5nKr5ME9wuvmI=
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.2 h1:HUqgAIb+TIrZPXRytYB5XqzkEXG2U4a4/ZGpprNtL5A=
github.com/libtnb/utils v1.2.2/go.mod h1:KUCx2+Phw3cLXOwuQDy6XwFOliaYuU1rpAuJQuHf+lQ=
github.com/libtnb/validator v0.4.3 h1:tq3nOOt9YIcrWgXmM9BQAr3ofjiUBAyeUDdHHlpip+o=
github.com/libtnb/validator v0.4.3/go.mod h1:yssIkchUNzAQc8EeP99rwIeYbFE2usrYbN1Ki5pamfQ=
github.com/libtnb/validator/contrib/openapi v0.2.2 h1:i+6l1ZvHsMQAOjfFgAR7wOHQMxQtORVqIHiYy8gScV4=
github.com/libtnb/validator/contrib/openapi v0.2.2/go.mod h1:8qT0y4bs8o400kv1YqD6K+4P04x128yPmnarLwWeHaQ=
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=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
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.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=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
@@ -255,17 +317,15 @@ 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.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
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=
github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E=
github.com/ncruces/go-sqlite3/gormlite v0.30.2 h1:FZ8mic14xTatssTkHCrelh9nPeFdXuzgMoNGkfuFbBU=
github.com/ncruces/go-sqlite3/gormlite v0.30.2/go.mod h1:W9WLBbqrrOIh2dqFZkeC/xKALG2LDIHY91jowahOdtI=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
@@ -275,12 +335,18 @@ github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsT
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
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=
@@ -295,21 +361,27 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
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=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
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/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
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.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
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=
@@ -329,11 +401,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/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
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=
@@ -342,45 +414,79 @@ github.com/timtadh/getopt v1.0.0/go.mod h1:L3EL6YN2G0eIAhYBo9b7SB9d/kEQmdnwthIlM
github.com/timtadh/lexmachine v0.2.2/go.mod h1:GBJvD5OAfRn/gnp92zb9KTgHLB7akKyxmVivoYCcjQI=
github.com/timtadh/lexmachine v0.2.3 h1:ZqlfHnfMcAygtbNM5Gv7jQf8hmM8LfVzDjfCrq235NQ=
github.com/timtadh/lexmachine v0.2.3/go.mod h1:oK1NW+93fQSIF6s+J6sXBFWsCPCFbNmrwKV1i0aqvW0=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701 h1:JgeHIJzRSEdcuLXufZrni5+a4yDnBhQG+DdKhqCFhq0=
github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701/go.mod h1:ALbEe81QPWOZjDKCKNWodG2iqCMtregG8+ebQgjx2+4=
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
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.11.0 h1:P/euJp99kb9p0tlVY+iYTLYYTAQlfl0hR2gUO1Img1Q=
github.com/urfave/cli/v3 v3.11.0/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=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
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=
@@ -392,6 +498,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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
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=
@@ -406,8 +514,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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
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=
@@ -416,8 +524,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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
@@ -434,20 +542,18 @@ 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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
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/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -465,6 +571,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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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=
@@ -498,14 +606,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.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/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.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.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
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.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.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
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-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=
+34 -26
View File
@@ -7,37 +7,38 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/bddjr/hlfhr"
"github.com/go-chi/chi/v5"
"github.com/go-gormigrate/gormigrate/v2"
"github.com/gookit/validate"
"github.com/robfig/cron/v3"
"github.com/libtnb/cron"
"github.com/acepanel/panel/pkg/config"
"github.com/acepanel/panel/pkg/queue"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/tlscert"
"github.com/acepanel/panel/v3/pkg/types"
)
type Ace struct {
conf *config.Config
router *chi.Mux
server *hlfhr.Server
reloader *tlscert.Reloader
migrator *gormigrate.Gormigrate
cron *cron.Cron
queue *queue.Queue
runner types.TaskRunner
}
func NewAce(conf *config.Config, router *chi.Mux, server *hlfhr.Server, migrator *gormigrate.Gormigrate, cron *cron.Cron, queue *queue.Queue, _ *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 {
return &Ace{
conf: conf,
router: router,
server: server,
reloader: reloader,
migrator: migrator,
cron: cron,
queue: queue,
runner: runner,
}
}
@@ -49,15 +50,17 @@ func (r *Ace) Run() error {
fmt.Println("[DB] database migrated")
// start cron scheduler
r.cron.Start()
if err := r.cron.Start(); err != nil {
return err
}
fmt.Println("[CRON] cron scheduler started")
// create context for queue
queueCtx, queueCancel := context.WithCancel(context.Background())
defer queueCancel()
// create context for runner
runnerCtx, runnerCancel := context.WithCancel(context.Background())
defer runnerCancel()
// start queue
r.queue.Run(queueCtx)
// start task runner
r.runner.Run(runnerCtx)
// setup graceful shutdown
quit := make(chan os.Signal, 1)
@@ -66,15 +69,12 @@ func (r *Ace) Run() error {
// run http server in goroutine
serverErr := make(chan error, 1)
go func() {
if r.conf.HTTP.TLS {
cert := filepath.Join(Root, "panel/storage/cert.pem")
key := filepath.Join(Root, "panel/storage/cert.key")
fmt.Println("[HTTP] listening and serving on port", r.conf.HTTP.Port, "with tls")
if err := r.server.ListenAndServeTLS(cert, key); !errors.Is(err, http.ErrServerClosed) {
fmt.Println("[HTTP] listening and serving on port", r.conf.HTTP.Port)
if r.conf.HTTP.IsHTTPS() {
if err := r.server.ListenAndServeTLS("", ""); !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
} else {
fmt.Println("[HTTP] listening and serving on port", r.conf.HTTP.Port)
if err := r.server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
@@ -89,20 +89,28 @@ func (r *Ace) Run() error {
return err
}
case sig := <-quit:
fmt.Println("\n[APP] received signal:", sig)
fmt.Println("[APP] received signal:", sig)
}
// graceful shutdown
fmt.Println("[APP] shutting down gracefully...")
// stop cron scheduler
ctx := r.cron.Stop()
<-ctx.Done()
cronCtx, cronCancel := context.WithTimeout(context.Background(), 30*time.Second)
_ = r.cron.Stop(cronCtx)
cronCancel()
fmt.Println("[CRON] cron scheduler stopped")
// stop queue
queueCancel()
fmt.Println("[QUEUE] queue stopped")
// stop task runner
runnerCancel()
fmt.Println("[QUEUE] task runner stopped")
// close certificate reloader
if r.reloader != nil {
if err := r.reloader.Close(); err != nil {
fmt.Println("[TLS] certificate reloader close error:", err)
}
}
// shutdown http server
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
+3 -9
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/pkg/apploader"
)
type Cli struct {
@@ -16,7 +13,7 @@ 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 {
IsCli = true
return &Cli{
cmd: cmd,
@@ -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)
}
+5
View File
@@ -1,5 +1,10 @@
package app
import "time"
// StartTime 面板启动时间
var StartTime = time.Now()
// 面板状态常量
const (
StatusNormal = iota
+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
}
+99 -9
View File
@@ -9,15 +9,18 @@ import (
"github.com/leonelquinteros/gotext"
"github.com/spf13/cast"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/pkg/tools"
"github.com/acepanel/panel/pkg/types"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/service"
"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/tools"
"github.com/acepanel/panel/v3/pkg/types"
)
var mpmEventRegexp = regexp.MustCompile(`(?s)<IfModule mpm_event_module>(.*?)</IfModule>`)
type App struct {
t *gotext.Locale
}
@@ -34,10 +37,17 @@ func (s *App) Route(r chi.Router) {
r.Post("/config", s.SaveConfig)
r.Get("/error_log", s.ErrorLog)
r.Post("/clear_error_log", s.ClearErrorLog)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("apache")
return types.AggregateAppStatus(ok)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/apache/conf/httpd.conf", app.Root))
config, err := io.Read(app.Root + "/server/apache/conf/httpd.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -53,7 +63,7 @@ func (s *App) SaveConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err = io.Write(fmt.Sprintf("%s/server/apache/conf/httpd.conf", app.Root), req.Config, 0600); err != nil {
if err = io.Write(app.Root+"/server/apache/conf/httpd.conf", req.Config, 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -155,3 +165,83 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
service.Success(w, data)
}
// GetConfigTune 获取 Apache 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
defaultConf, err := io.Read(app.Root + "/server/apache/conf/extra/httpd-default.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
mpmConf, _ := io.Read(app.Root + "/server/apache/conf/extra/httpd-mpm.conf")
eventBlock := ""
if m := mpmEventRegexp.FindStringSubmatch(mpmConf); len(m) > 1 {
eventBlock = m[1]
}
// 面板统一写入 httpd-default.conf,未写入过的从 httpd-mpm.conf 的 event 块读取默认值
get := func(key string) string {
if v := confval.Directive.Get(defaultConf, key); v != "" {
return v
}
return confval.Directive.Get(eventBlock, key)
}
tune := ConfigTune{
// MPM 事件模型
StartServers: get("StartServers"),
MinSpareThreads: get("MinSpareThreads"),
MaxSpareThreads: get("MaxSpareThreads"),
ThreadsPerChild: get("ThreadsPerChild"),
MaxRequestWorkers: get("MaxRequestWorkers"),
MaxConnectionsPerChild: get("MaxConnectionsPerChild"),
// 连接设置
Timeout: get("Timeout"),
KeepAlive: get("KeepAlive"),
MaxKeepAliveRequests: get("MaxKeepAliveRequests"),
KeepAliveTimeout: get("KeepAliveTimeout"),
}
service.Success(w, tune)
}
// UpdateConfigTune 更新 Apache 配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
confPath := app.Root + "/server/apache/conf/extra/httpd-default.conf"
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// MPM 参数一并写入 httpd-default.conf,其 Include 顺序在 httpd-mpm.conf 之后,顶层定义覆盖块内默认值
config = confval.Directive.Set(config, "StartServers", req.StartServers)
config = confval.Directive.Set(config, "MinSpareThreads", req.MinSpareThreads)
config = confval.Directive.Set(config, "MaxSpareThreads", req.MaxSpareThreads)
config = confval.Directive.Set(config, "ThreadsPerChild", req.ThreadsPerChild)
config = confval.Directive.Set(config, "MaxRequestWorkers", req.MaxRequestWorkers)
config = confval.Directive.Set(config, "MaxConnectionsPerChild", req.MaxConnectionsPerChild)
config = confval.Directive.Set(config, "Timeout", req.Timeout)
config = confval.Directive.Set(config, "KeepAlive", req.KeepAlive)
config = confval.Directive.Set(config, "MaxKeepAliveRequests", req.MaxKeepAliveRequests)
config = confval.Directive.Set(config, "KeepAliveTimeout", req.KeepAliveTimeout)
if err = io.Write(confPath, config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Reload("apache"); err != nil {
out, _ := shell.Execf("%s/server/apache/bin/apachectl configtest", app.Root)
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload apache: %v %s", err, out))
return
}
service.Success(w, nil)
}
+16
View File
@@ -3,3 +3,19 @@ package apache
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// ConfigTune Apache 配置调整
type ConfigTune struct {
// MPM 事件模型
StartServers string `form:"start_servers" json:"start_servers"`
MinSpareThreads string `form:"min_spare_threads" json:"min_spare_threads"`
MaxSpareThreads string `form:"max_spare_threads" json:"max_spare_threads"`
ThreadsPerChild string `form:"threads_per_child" json:"threads_per_child"`
MaxRequestWorkers string `form:"max_request_workers" json:"max_request_workers"`
MaxConnectionsPerChild string `form:"max_connections_per_child" json:"max_connections_per_child"`
// 连接设置
Timeout string `form:"timeout" json:"timeout"`
KeepAlive string `form:"keep_alive" json:"keep_alive"`
MaxKeepAliveRequests string `form:"max_keep_alive_requests" json:"max_keep_alive_requests"`
KeepAliveTimeout string `form:"keep_alive_timeout" json:"keep_alive_timeout"`
}
+41 -42
View File
@@ -3,49 +3,48 @@ package apps
import (
"github.com/google/wire"
"github.com/acepanel/panel/internal/apps/apache"
"github.com/acepanel/panel/internal/apps/codeserver"
"github.com/acepanel/panel/internal/apps/docker"
"github.com/acepanel/panel/internal/apps/fail2ban"
"github.com/acepanel/panel/internal/apps/frp"
"github.com/acepanel/panel/internal/apps/gitea"
"github.com/acepanel/panel/internal/apps/mariadb"
"github.com/acepanel/panel/internal/apps/memcached"
"github.com/acepanel/panel/internal/apps/minio"
"github.com/acepanel/panel/internal/apps/mysql"
"github.com/acepanel/panel/internal/apps/nginx"
"github.com/acepanel/panel/internal/apps/openresty"
"github.com/acepanel/panel/internal/apps/percona"
"github.com/acepanel/panel/internal/apps/phpmyadmin"
"github.com/acepanel/panel/internal/apps/podman"
"github.com/acepanel/panel/internal/apps/postgresql"
"github.com/acepanel/panel/internal/apps/pureftpd"
"github.com/acepanel/panel/internal/apps/redis"
"github.com/acepanel/panel/internal/apps/rsync"
"github.com/acepanel/panel/internal/apps/s3fs"
"github.com/acepanel/panel/internal/apps/supervisor"
"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/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"
"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"
)
var ProviderSet = wire.NewSet(
apache.NewApp,
codeserver.NewApp,
docker.NewApp,
fail2ban.NewApp,
frp.NewApp,
gitea.NewApp,
mariadb.NewApp,
memcached.NewApp,
minio.NewApp,
mysql.NewApp,
nginx.NewApp,
openresty.NewApp,
percona.NewApp,
phpmyadmin.NewApp,
podman.NewApp,
postgresql.NewApp,
pureftpd.NewApp,
redis.NewApp,
rsync.NewApp,
s3fs.NewApp,
supervisor.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,
)
+295
View File
@@ -0,0 +1,295 @@
package clickhouse
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"go.yaml.in/yaml/v4"
"resty.dev/v3"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
settingRepo biz.SettingRepo
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) *App {
return &App{
t: t,
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
}
}
func (s *App) Route(r chi.Router) {
r.Get("/load", s.Load)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
r.Get("/default_password", s.GetDefaultPassword)
r.Post("/default_password", s.SetDefaultPassword)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("clickhouse-server")
return types.AggregateAppStatus(ok)
}
// Load 获取 ClickHouse 运行状态
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, _ := systemctl.Status("clickhouse-server")
if !status {
service.Success(w, []types.NV{})
return
}
password, _ := s.settingRepo.Get(biz.SettingKeyClickHouseDefaultPassword)
port := s.getPort()
client := resty.New().SetTimeout(10 * time.Second)
defer func(client *resty.Client) { _ = client.Close() }(client)
// 获取版本
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.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
version := strings.TrimSpace(string(versionResp.Bytes()))
// 获取运行时间
uptimeResp, _ := client.R().Get(fmt.Sprintf("http://127.0.0.1:%s/?query=SELECT+uptime()&user=default&password=%s", port, password))
uptime := strings.TrimSpace(string(uptimeResp.Bytes()))
// 获取当前查询数
queriesResp, _ := client.R().Get(fmt.Sprintf("http://127.0.0.1:%s/?query=SELECT+value+FROM+system.metrics+WHERE+metric='Query'&user=default&password=%s", port, password))
queries := strings.TrimSpace(string(queriesResp.Bytes()))
// 获取内存使用
memResp, _ := client.R().Get(fmt.Sprintf("http://127.0.0.1:%s/?query=SELECT+value+FROM+system.metrics+WHERE+metric='MemoryTracking'&user=default&password=%s", port, password))
memUsage := strings.TrimSpace(string(memResp.Bytes()))
// 获取数据库数量
dbCountResp, _ := client.R().Get(fmt.Sprintf("http://127.0.0.1:%s/?query=SELECT+count()+FROM+system.databases&user=default&password=%s", port, password))
dbCount := strings.TrimSpace(string(dbCountResp.Bytes()))
// 获取表数量
tableCountResp, _ := client.R().Get(fmt.Sprintf("http://127.0.0.1:%s/?query=SELECT+count()+FROM+system.tables&user=default&password=%s", port, password))
tableCount := strings.TrimSpace(string(tableCountResp.Bytes()))
data := []types.NV{
{Name: s.t.Get("Version"), Value: version},
{Name: s.t.Get("Uptime (seconds)"), Value: uptime},
{Name: s.t.Get("Active Queries"), Value: queries},
{Name: s.t.Get("Memory Usage (bytes)"), Value: memUsage},
{Name: s.t.Get("Databases"), Value: dbCount},
{Name: s.t.Get("Tables"), Value: tableCount},
}
service.Success(w, data)
}
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
common.ServeConfig(w, s.configPath())
}
// UpdateConfig 更新配置
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
common.SaveConfig(w, r, s.configPath(), "clickhouse-server")
}
// GetConfigTune 获取配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
raw, _ := io.Read(s.configPath())
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg == nil {
cfg = make(map[string]any)
}
tune := ConfigTune{
ListenHost: confval.GetYAML(cfg, "listen_host"),
HTTPPort: confval.GetYAML(cfg, "http_port"),
TCPPort: confval.GetYAML(cfg, "tcp_port"),
MaxMemoryUsage: confval.GetYAML(cfg, "max_memory_usage"),
MaxThreads: confval.GetYAML(cfg, "max_threads"),
Path: confval.GetYAML(cfg, "path"),
TmpPath: confval.GetYAML(cfg, "tmp_path"),
LogLevel: confval.GetYAML(cfg, "logger.level"),
}
service.Success(w, tune)
}
// UpdateConfigTune 更新配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
raw, _ := io.Read(s.configPath())
var cfg map[string]any
if err = yaml.Unmarshal([]byte(raw), &cfg); err != nil {
cfg = make(map[string]any)
}
// ClickHouse 顶层键直接用平铺方式
s.setYAMLValue(cfg, "listen_host", req.ListenHost)
s.setYAMLValue(cfg, "http_port", req.HTTPPort)
s.setYAMLValue(cfg, "tcp_port", req.TCPPort)
s.setYAMLValue(cfg, "max_memory_usage", req.MaxMemoryUsage)
s.setYAMLValue(cfg, "max_threads", req.MaxThreads)
s.setYAMLValue(cfg, "path", req.Path)
s.setYAMLValue(cfg, "tmp_path", req.TmpPath)
// logger.level 是嵌套的
s.setNestedYAMLValue(cfg, "logger.level", req.LogLevel)
data, err := yaml.Marshal(cfg)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(s.configPath(), string(data), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("clickhouse-server"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// GetDefaultPassword 获取 default 用户密码
func (s *App) GetDefaultPassword(w http.ResponseWriter, r *http.Request) {
password, err := s.settingRepo.Get(biz.SettingKeyClickHouseDefaultPassword)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get ClickHouse default password: %v", err))
return
}
service.Success(w, password)
}
// SetDefaultPassword 设置 default 用户密码
func (s *App) SetDefaultPassword(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[SetDefaultPassword](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 计算 SHA256 哈希
hash := sha256.Sum256([]byte(req.Password))
hexHash := hex.EncodeToString(hash[:])
// 读取 users.d/default.yaml 并更新密码
raw, _ := io.Read(s.usersConfigPath())
var cfg map[string]any
if err = yaml.Unmarshal([]byte(raw), &cfg); err != nil {
cfg = make(map[string]any)
}
users, _ := cfg["users"].(map[string]any)
if users == nil {
users = make(map[string]any)
cfg["users"] = users
}
def, _ := users["default"].(map[string]any)
if def == nil {
def = make(map[string]any)
users["default"] = def
}
def["password_sha256_hex"] = hexHash
out, _ := yaml.Marshal(cfg)
if err = io.Write(s.usersConfigPath(), string(out), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to write ClickHouse user config: %v", err))
return
}
// 重启服务使密码生效
if err = systemctl.Restart("clickhouse-server"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 保存明文到面板数据库
if err = s.settingRepo.Set(biz.SettingKeyClickHouseDefaultPassword, req.Password); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to save ClickHouse default password: %v", err))
return
}
_ = s.databaseServerRepo.UpdatePassword("local_clickhouse", req.Password)
service.Success(w, nil)
}
// configPath 返回主配置文件路径
func (s *App) configPath() string {
return app.Root + "/server/clickhouse/config/config.yaml"
}
// usersConfigPath 返回用户密码配置文件路径(users.d/ 由 ConfigProcessor 自动合并到 users.yaml
func (s *App) usersConfigPath() string {
return app.Root + "/server/clickhouse/config/users.d/default.yaml"
}
// getPort 从配置中获取 HTTP 端口
func (s *App) getPort() string {
raw, _ := io.Read(s.configPath())
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg != nil {
if v := confval.GetYAML(cfg, "http_port"); v != "" {
return v
}
}
return "8123"
}
// setYAMLValue 设置平铺 YAML 值
func (s *App) setYAMLValue(cfg map[string]any, key string, value string) {
if value == "" {
return
}
cfg[key] = value
}
// setNestedYAMLValue 设置嵌套 YAML 值
func (s *App) setNestedYAMLValue(cfg map[string]any, key string, value string) {
if value == "" {
return
}
parts := strings.SplitN(key, ".", 2)
if len(parts) == 1 {
cfg[parts[0]] = value
return
}
nested, ok := cfg[parts[0]].(map[string]any)
if !ok {
nested = make(map[string]any)
cfg[parts[0]] = nested
}
s.setNestedYAMLValue(nested, parts[1], value)
}
+22
View File
@@ -0,0 +1,22 @@
package clickhouse
// SetDefaultPassword 设置 default 用户密码
type SetDefaultPassword struct {
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" 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" validate:"unix_path"`
TmpPath string `form:"tmp_path" json:"tmp_path" validate:"unix_path"`
// 日志
LogLevel string `form:"log_level" json:"log_level" validate:"in:trace,debug,information,warning,error"`
}
+9 -3
View File
@@ -5,9 +5,10 @@ import (
"github.com/go-chi/chi/v5"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct{}
@@ -21,6 +22,11 @@ func (s *App) Route(r chi.Router) {
r.Post("/config", s.UpdateConfig)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("code-server")
return types.AggregateAppStatus(ok)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read("/root/.config/code-server/config.yaml")
service.Success(w, config)
+39
View File
@@ -0,0 +1,39 @@
// Package common 承载各应用共用的 HTTP 处理逻辑
package common
import (
"net/http"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
)
// ServeConfig 返回配置文件原始内容
func ServeConfig(w http.ResponseWriter, path string) {
config, _ := io.Read(path)
service.Success(w, config)
}
// SaveConfig 写入配置文件并重启对应服务
func SaveConfig(w http.ResponseWriter, r *http.Request, path, unit string) {
req, err := service.Bind[request.AppUpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(path, req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart(unit); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
+297
View File
@@ -0,0 +1,297 @@
// Package confval 读写行式配置文件中的键值项
package confval
import (
"slices"
"strings"
)
// Codec 描述一种行式配置文件的书写约定
type Codec struct {
// Delim 读取时分隔键与值的字符,空表示按空白切分
Delim string
// Assign 写入时的键值分隔符,如 " "、"="、" = "
Assign string
// Terminator 行尾终止符,如 nginx 的 ";"
Terminator string
// Quote 写入时给值添加的引号,读取时一并去除
Quote string
// Comments 识别为注释的前缀,第一个用于注释掉配置项
Comments []string
// Sections 是否按 [section] 分组匹配
Sections bool
// InlineComment 该格式支持行尾注释
// 仅对确实支持的格式开启:redis.conf 与 java properties 不支持,
// 值里的 "#" 是数据(如密码),误判会损坏配置
InlineComment bool
// RequireValue 匹配时要求该行已带值,用于区分「开关」与「键 值」
RequireValue bool
// AppendIndent 追加新配置项时使用的缩进
AppendIndent string
}
// line 为一行配置解析后的各组成部分
type line struct {
indent string // 行首缩进
commented string // 命中的注释前缀,非注释行为空
key string
value string // 已去引号
comment string // 行尾注释(含前缀),无则为空
ok bool // 是否为可识别的键值行
}
// 各应用配置文件的书写约定
var (
// Directive 「键 值」,用于 redis、valkey
Directive = Codec{Assign: " ", Comments: []string{"#"}}
// FTP 「键 值」,pure-ftpd 的配置项必须带值
FTP = Codec{Assign: " ", Comments: []string{"#"}, RequireValue: true}
// Properties 「键=值」,用于 kafka、rocketmq
Properties = Codec{Delim: "=", Assign: "=", Comments: []string{"#"}}
// Nginx 「键 值;」,支持行尾注释
Nginx = Codec{Assign: " ", Terminator: ";", Comments: []string{"#"}, InlineComment: true, RequireValue: true, AppendIndent: " "}
// Postgres 「键 = '值'」,支持行尾注释
Postgres = Codec{Delim: "=", Assign: " = ", Quote: "'", Comments: []string{"#"}, InlineComment: true}
// INI 「键 = 值」,忽略分组,用于 mysql
INI = Codec{Delim: "=", Assign: " = ", Comments: []string{"#", ";"}}
// SectionINI 分组感知的 INI,用于 grafana
SectionINI = Codec{Delim: "=", Assign: " = ", Comments: []string{"#", ";"}, Sections: true}
// PHPINI PHP 的 ini,注释优先使用 ";"
PHPINI = Codec{Delim: "=", Assign: " = ", Comments: []string{";", "#"}}
// Supervisor 「键=值」,注释同时支持 ";" 与 "#"
Supervisor = Codec{Delim: "=", Assign: "=", Comments: []string{";", "#"}}
)
// Get 读取配置项,未命中返回空串
func (c Codec) Get(content, key string) string {
return c.GetIn(content, "", key)
}
// GetIn 在指定分组内读取配置项,section 为空表示不限分组
func (c Codec) GetIn(content, section, key string) string {
current := ""
for raw := range strings.SplitSeq(content, "\n") {
if name, ok := sectionName(raw); ok {
current = name
continue
}
if c.Sections && current != section {
continue
}
// 被注释掉的配置项视为未设置
parsed := c.parse(raw)
if parsed.ok && parsed.commented == "" && parsed.key == key {
return parsed.value
}
}
return ""
}
// Set 写入配置项,值为空时注释掉该项
func (c Codec) Set(content, key, value string) string {
return c.SetIn(content, "", key, value)
}
// SetIn 在指定分组内写入配置项,分组不存在时在末尾补建
func (c Codec) SetIn(content, section, key, value string) string {
value = strings.NewReplacer("\n", "", "\r", "").Replace(value)
raws := strings.Split(content, "\n")
result := make([]string, 0, len(raws)+3)
current := ""
found := false
// 目标分组最后一行在 result 中的位置,用于在组内补插新项
sectionEnd := -1
for _, raw := range raws {
if name, ok := sectionName(raw); ok {
current = name
result = append(result, raw)
continue
}
if c.Sections && current == section && strings.TrimSpace(raw) != "" {
sectionEnd = len(result)
}
if c.Sections && current != section {
result = append(result, raw)
continue
}
// 注释掉的配置项同样参与匹配,便于重新启用
parsed := c.parse(raw)
if !parsed.ok || parsed.key != key {
result = append(result, raw)
continue
}
// 同名项只保留第一处,后续重复行丢弃
if found {
continue
}
found = true
if value == "" {
// 值为空时注释掉该配置项,已注释的原样保留
if parsed.commented == "" {
result = append(result, parsed.indent+c.comment()+" "+strings.TrimSpace(raw))
} else {
result = append(result, raw)
}
continue
}
// 保留原行的缩进与行尾注释,只替换值
result = append(result, parsed.indent+c.format(key, value)+trailing(parsed.comment))
if c.Sections {
sectionEnd = len(result) - 1
}
}
if found || value == "" {
return strings.Join(result, "\n")
}
newLine := c.AppendIndent + c.format(key, value)
switch {
case !c.Sections:
result = append(result, newLine)
case sectionEnd >= 0:
result = slices.Insert(result, sectionEnd+1, newLine)
default:
// 分组不存在,在文件末尾补建
result = append(result, "", "["+section+"]", newLine)
}
return strings.Join(result, "\n")
}
// parse 将一行拆解为缩进、注释状态、键、值与行尾注释
func (c Codec) parse(raw string) line {
parsed := line{indent: indentOf(raw)}
body := strings.TrimSpace(raw)
if body == "" {
return parsed
}
if parsed.commented = c.commentOf(body); parsed.commented != "" {
body = strings.TrimSpace(strings.TrimPrefix(body, parsed.commented))
}
// 行尾注释在终止符之后,需先摘除
if c.InlineComment {
body, parsed.comment = c.cutComment(body)
}
if c.Terminator != "" {
if !strings.HasSuffix(body, c.Terminator) {
return parsed
}
body = strings.TrimSpace(strings.TrimSuffix(body, c.Terminator))
}
key, value, ok := c.split(body)
if !ok {
return parsed
}
parsed.key, parsed.value, parsed.ok = key, c.unquote(value), true
return parsed
}
// split 从已剥离注释与终止符的正文中取出键与值
func (c Codec) split(body string) (key, value string, ok bool) {
if c.Delim != "" {
k, v, found := strings.Cut(body, c.Delim)
if !found {
return "", "", false
}
return strings.TrimSpace(k), strings.TrimSpace(v), true
}
parts := strings.Fields(body)
if len(parts) == 0 || (c.RequireValue && len(parts) < 2) {
return "", "", false
}
return parts[0], strings.Join(parts[1:], " "), true
}
// cutComment 切出行尾注释,引号内的注释符是数据而非注释
func (c Codec) cutComment(body string) (string, string) {
inSingle, inDouble := false, false
for i := range len(body) {
switch ch := body[i]; {
case ch == '\'' && !inDouble:
inSingle = !inSingle
case ch == '"' && !inSingle:
inDouble = !inDouble
case !inSingle && !inDouble:
for _, prefix := range c.Comments {
if strings.HasPrefix(body[i:], prefix) {
return strings.TrimRight(body[:i], " \t"), body[i:]
}
}
}
}
return body, ""
}
// unquote 去除值两端的引号
func (c Codec) unquote(value string) string {
if c.Quote == "" {
return value
}
return strings.Trim(value, `'"`)
}
// format 拼出一条完整的配置行
func (c Codec) format(key, value string) string {
return key + c.Assign + c.Quote + value + c.Quote + c.Terminator
}
// comment 注释掉配置项时使用的前缀
func (c Codec) comment() string {
if len(c.Comments) == 0 {
return "#"
}
return c.Comments[0]
}
// commentOf 返回该行命中的注释前缀,非注释行返回空串
func (c Codec) commentOf(trimmed string) string {
for _, prefix := range c.Comments {
if strings.HasPrefix(trimmed, prefix) {
return prefix
}
}
return ""
}
// trailing 把行尾注释接回改写后的行
func trailing(comment string) string {
if comment == "" {
return ""
}
return " " + comment
}
// sectionName 解析 [section] 行
func sectionName(raw string) (string, bool) {
trimmed := strings.TrimSpace(raw)
if !strings.HasPrefix(trimmed, "[") || !strings.HasSuffix(trimmed, "]") {
return "", false
}
return strings.TrimSpace(trimmed[1 : len(trimmed)-1]), true
}
// indentOf 取出行首缩进
func indentOf(raw string) string {
return raw[:len(raw)-len(strings.TrimLeft(raw, " \t"))]
}
+179
View File
@@ -0,0 +1,179 @@
package confval
import (
"testing"
"github.com/stretchr/testify/suite"
)
type ConfValTestSuite struct {
suite.Suite
}
func TestConfValTestSuite(t *testing.T) {
suite.Run(t, new(ConfValTestSuite))
}
func (s *ConfValTestSuite) TestDirectiveGet() {
conf := "# comment\nbind 127.0.0.1 ::1\nport 6379\n\n# maxmemory 100mb\n"
s.Equal("127.0.0.1 ::1", Directive.Get(conf, "bind"))
s.Equal("6379", Directive.Get(conf, "port"))
// 注释掉的项不应被读出
s.Empty(Directive.Get(conf, "maxmemory"))
s.Empty(Directive.Get(conf, "absent"))
}
func (s *ConfValTestSuite) TestDirectiveSetReplaces() {
s.Equal("port 6380", Directive.Set("port 6379", "port", "6380"))
}
func (s *ConfValTestSuite) TestDirectiveSetAppendsWhenMissing() {
s.Equal("port 6379\ntimeout 30", Directive.Set("port 6379", "timeout", "30"))
}
func (s *ConfValTestSuite) TestDirectiveSetCommentsOutEmptyValue() {
s.Equal("# port 6379", Directive.Set("port 6379", "port", ""))
// 已注释的保持原样,不重复加注释符
s.Equal("# port 6379", Directive.Set("# port 6379", "port", ""))
}
func (s *ConfValTestSuite) TestDirectiveSetReenablesCommented() {
s.Equal("maxmemory 200mb", Directive.Set("# maxmemory 100mb", "maxmemory", "200mb"))
}
func (s *ConfValTestSuite) TestDirectiveSetOnlyKeepsFirstMatch() {
s.Equal("port 6380", Directive.Set("port 6379\nport 7000", "port", "6380"))
}
func (s *ConfValTestSuite) TestSetStripsNewlinesFromValue() {
s.Equal("port 63opq79", Directive.Set("port 1", "port", "63\nopq\r79"))
}
func (s *ConfValTestSuite) TestFTPRequiresValueToMatch() {
// 裸键行不算配置项,避免把开关误判为键值对
s.Empty(FTP.Get("NoAnonymous\n", "NoAnonymous"))
s.Equal("yes", FTP.Get("NoAnonymous yes\n", "NoAnonymous"))
}
func (s *ConfValTestSuite) TestPropertiesRoundTrip() {
conf := "# broker\nnum.network.threads=3\n"
s.Equal("3", Properties.Get(conf, "num.network.threads"))
s.Equal("# broker\nnum.network.threads=5\n", Properties.Set(conf, "num.network.threads", "5"))
}
func (s *ConfValTestSuite) TestNginxTerminatorAndIndent() {
conf := "http {\n keepalive_timeout 60;\n}"
s.Equal("60", Nginx.Get(conf, "keepalive_timeout"))
// 缩进保留,行尾分号补回
s.Equal("http {\n keepalive_timeout 75;\n}", Nginx.Set(conf, "keepalive_timeout", "75"))
}
func (s *ConfValTestSuite) TestNginxIgnoresLinesWithoutTerminator() {
s.Empty(Nginx.Get("http {\n keepalive_timeout 60\n}", "keepalive_timeout"))
}
func (s *ConfValTestSuite) TestNginxKeepsTrailingComment() {
conf := "http {\n keepalive_timeout 60; # 保持连接\n}"
// 注释在分号之后,读取时不能混进值里
s.Equal("60", Nginx.Get(conf, "keepalive_timeout"))
s.Equal("http {\n keepalive_timeout 75; # 保持连接\n}", Nginx.Set(conf, "keepalive_timeout", "75"))
}
func (s *ConfValTestSuite) TestCommentOutKeepsWholeLine() {
// 注释掉时整行保留,行尾注释不丢
s.Equal("# max_connections = 100 # note", Postgres.Set("max_connections = 100 # note", "max_connections", ""))
}
func (s *ConfValTestSuite) TestNginxAppendsWithIndent() {
s.Equal("a b;\n c d;", Nginx.Set("a b;", "c", "d"))
}
func (s *ConfValTestSuite) TestPostgresQuotesAndInlineComment() {
conf := "max_connections = 100 # note\nlisten_addresses = '*'\n"
s.Equal("100", Postgres.Get(conf, "max_connections"))
s.Equal("*", Postgres.Get(conf, "listen_addresses"))
// 只替换值,行尾注释保留
s.Equal("max_connections = '200' # note\nlisten_addresses = '*'\n", Postgres.Set(conf, "max_connections", "200"))
}
func (s *ConfValTestSuite) TestInlineCommentNotAppliedWhereUnsupported() {
// redis.conf 不支持行尾注释,密码里的 # 是数据不能当注释切掉
s.Equal("p@ss#word", Directive.Get("requirepass p@ss#word\n", "requirepass"))
s.Equal("requirepass a#b", Directive.Set("requirepass p@ss#word", "requirepass", "a#b"))
// java properties 同理
s.Equal("a#b", Properties.Get("pass=a#b\n", "pass"))
}
func (s *ConfValTestSuite) TestQuotedCommentCharIsData() {
s.Equal("a#b", Postgres.Get("password = 'a#b' # real note\n", "password"))
s.Equal("password = 'x#y' # real note", Postgres.Set("password = 'a#b' # real note", "password", "x#y"))
}
func (s *ConfValTestSuite) TestINIIgnoresSectionHeaders() {
conf := "[mysqld]\nport = 3306\n; skip-name-resolve = 1\n"
s.Equal("3306", INI.Get(conf, "port"))
s.Empty(INI.Get(conf, "mysqld"))
// 分号注释同样能被重新启用
s.Equal("[mysqld]\nport = 3306\nskip-name-resolve = 2\n", INI.Set(conf, "skip-name-resolve", "2"))
}
func (s *ConfValTestSuite) TestSectionINIMatchesOnlyTargetSection() {
conf := "[server]\nhttp_port = 3000\n\n[database]\nhttp_port = 9999\n"
s.Equal("3000", SectionINI.GetIn(conf, "server", "http_port"))
s.Equal("9999", SectionINI.GetIn(conf, "database", "http_port"))
s.Empty(SectionINI.GetIn(conf, "absent", "http_port"))
got := SectionINI.SetIn(conf, "database", "http_port", "8888")
s.Equal("3000", SectionINI.GetIn(got, "server", "http_port"))
s.Equal("8888", SectionINI.GetIn(got, "database", "http_port"))
}
func (s *ConfValTestSuite) TestSectionINIInsertsInsideSection() {
conf := "[server]\nhttp_port = 3000\n\n[database]\ntype = sqlite3\n"
got := SectionINI.SetIn(conf, "server", "domain", "example.com")
s.Equal("example.com", SectionINI.GetIn(got, "server", "domain"))
// 新项必须落在 server 段内,不能漏进 database 段
s.Empty(SectionINI.GetIn(got, "database", "domain"))
}
func (s *ConfValTestSuite) TestSectionINICreatesMissingSection() {
got := SectionINI.SetIn("[server]\nhttp_port = 3000\n", "smtp", "host", "localhost:25")
s.Equal("localhost:25", SectionINI.GetIn(got, "smtp", "host"))
s.Equal("3000", SectionINI.GetIn(got, "server", "http_port"))
}
func (s *ConfValTestSuite) TestPHPINICommentsWithSemicolon() {
s.Equal("; memory_limit = 128M", PHPINI.Set("memory_limit = 128M", "memory_limit", ""))
}
func (s *ConfValTestSuite) TestGetYAMLFlatAndNested() {
s.Equal("single-node", GetYAML(map[string]any{"discovery.type": "single-node"}, "discovery.type"))
s.Equal("single-node", GetYAML(map[string]any{"discovery": map[string]any{"type": "single-node"}}, "discovery.type"))
s.Empty(GetYAML(map[string]any{}, "discovery.type"))
}
func (s *ConfValTestSuite) TestSetYAMLFlattensAndClearsNested() {
cfg := map[string]any{"discovery": map[string]any{"type": "old"}}
SetYAML(cfg, "discovery.type", "single-node")
s.Equal("single-node", cfg["discovery.type"])
// 嵌套键清空后整个父键一并移除
_, ok := cfg["discovery"]
s.False(ok)
}
func (s *ConfValTestSuite) TestSetYAMLIgnoresEmptyValue() {
cfg := map[string]any{}
SetYAML(cfg, "discovery.type", "")
s.Empty(cfg)
}
+132
View File
@@ -0,0 +1,132 @@
package confval
import (
"slices"
"strconv"
"strings"
"github.com/spf13/cast"
)
// tomlCodec TOML 根表的书写约定,值的引号由 formatTOML 按类型决定
var tomlCodec = Codec{Delim: "=", Assign: " = ", Comments: []string{"#"}, InlineComment: true}
// GetTOML 读取根表配置项,兼容点号键(auth.token = "x")与分组写法([auth] 下的 token = "x"
func GetTOML(content, key string) string {
section, leaf, nested := cutLast(key)
current := ""
for raw := range strings.SplitSeq(content, "\n") {
if name, ok := tableName(raw); ok {
current = name
continue
}
parsed := tomlCodec.parse(raw)
if !parsed.ok || parsed.commented != "" {
continue
}
// 根表下写点号全键,分组内写末段键,两种写法都算命中
matched := (current == "" && parsed.key == key) || (nested && current == section && parsed.key == leaf)
if matched {
return unquoteTOML(parsed.value)
}
}
return ""
}
// SetTOML 写入根表配置项,值为空时注释掉该项;新键以点号形式插入到首个表头之前
func SetTOML(content, key string, value any) string {
literal := formatTOML(value)
section, leaf, nested := cutLast(key)
raws := strings.Split(content, "\n")
result := make([]string, 0, len(raws)+1)
current := ""
found := false
// 首个表头在 result 中的位置,新键只能插在它之前才仍属于根表
firstTable := -1
for _, raw := range raws {
if name, ok := tableName(raw); ok {
current = name
if firstTable < 0 {
firstTable = len(result)
}
result = append(result, raw)
continue
}
parsed := tomlCodec.parse(raw)
matched := parsed.ok && ((current == "" && parsed.key == key) || (nested && current == section && parsed.key == leaf))
if !matched || found {
result = append(result, raw)
continue
}
found = true
if literal == "" {
// 值为空时注释掉该配置项,已注释的原样保留
if parsed.commented == "" {
result = append(result, parsed.indent+"# "+strings.TrimSpace(raw))
} else {
result = append(result, raw)
}
continue
}
// 保留原行的写法、缩进与行尾注释,只替换值
result = append(result, parsed.indent+parsed.key+tomlCodec.Assign+literal+trailing(parsed.comment))
}
if found || literal == "" {
return strings.Join(result, "\n")
}
if firstTable < 0 {
firstTable = len(result)
}
return strings.Join(slices.Insert(result, firstTable, key+tomlCodec.Assign+literal), "\n")
}
// formatTOML 零值返回空串,表示删除该项
func formatTOML(value any) string {
switch v := value.(type) {
case string:
if v == "" {
return ""
}
return strconv.Quote(v)
case bool:
return strconv.FormatBool(v)
default:
return cast.ToString(v)
}
}
// unquoteTOML 去除字符串值的引号,非字符串值原样返回
func unquoteTOML(value string) string {
if unquoted, err := strconv.Unquote(value); err == nil {
return unquoted
}
return value
}
// tableName 解析 [table] 与 [[table]] 行,后者比 sectionName 多剥一层括号
func tableName(raw string) (string, bool) {
name, ok := sectionName(raw)
return strings.Trim(name, "[]"), ok
}
// cutLast 切出点号键的分组前缀与末段,如 auth.token -> auth, token
func cutLast(key string) (section, leaf string, nested bool) {
idx := strings.LastIndex(key, ".")
if idx < 0 {
return "", key, false
}
return key[:idx], key[idx+1:], true
}
+84
View File
@@ -0,0 +1,84 @@
package confval
import (
"testing"
"github.com/stretchr/testify/suite"
)
type TOMLTestSuite struct {
suite.Suite
}
func TestTOMLTestSuite(t *testing.T) {
suite.Run(t, new(TOMLTestSuite))
}
func (s *TOMLTestSuite) TestGetDottedKey() {
conf := "bindPort = 7000\nauth.token = \"12345678\"\n# maxPortsPerClient = 0\n"
s.Equal("7000", GetTOML(conf, "bindPort"))
s.Equal("12345678", GetTOML(conf, "auth.token"))
// 注释掉的项视为未设置
s.Empty(GetTOML(conf, "maxPortsPerClient"))
s.Empty(GetTOML(conf, "absent"))
}
func (s *TOMLTestSuite) TestGetSectionKey() {
conf := "bindPort = 7000\n\n[auth]\nmethod = \"token\"\ntoken = \"abc\"\n"
s.Equal("token", GetTOML(conf, "auth.method"))
s.Equal("abc", GetTOML(conf, "auth.token"))
}
func (s *TOMLTestSuite) TestGetIgnoresProxyBlock() {
conf := "serverAddr = \"1.1.1.1\"\n\n[[proxies]]\nname = \"ssh\"\nserverAddr = \"2.2.2.2\"\n"
s.Equal("1.1.1.1", GetTOML(conf, "serverAddr"))
}
func (s *TOMLTestSuite) TestSetReplacesInPlace() {
conf := "# 监听端口\nbindPort = 7000 # 默认\nauth.token = \"old\"\n"
s.Equal("# 监听端口\nbindPort = 7001 # 默认\nauth.token = \"old\"\n", SetTOML(conf, "bindPort", 7001))
s.Equal("# 监听端口\nbindPort = 7000 # 默认\nauth.token = \"new\"\n", SetTOML(conf, "auth.token", "new"))
}
func (s *TOMLTestSuite) TestSetReplacesInSection() {
conf := "bindPort = 7000\n\n[auth]\ntoken = \"old\"\n"
s.Equal("bindPort = 7000\n\n[auth]\ntoken = \"new\"\n", SetTOML(conf, "auth.token", "new"))
}
func (s *TOMLTestSuite) TestSetInsertsBeforeFirstTable() {
conf := "bindPort = 7000\n\n[[proxies]]\nname = \"ssh\"\n"
s.Equal("bindPort = 7000\n\nauth.token = \"abc\"\n[[proxies]]\nname = \"ssh\"\n", SetTOML(conf, "auth.token", "abc"))
}
func (s *TOMLTestSuite) TestSetAppendsWhenNoTable() {
s.Equal("bindPort = 7000\nauth.token = \"abc\"", SetTOML("bindPort = 7000", "auth.token", "abc"))
}
func (s *TOMLTestSuite) TestSetCommentsOutEmptyValue() {
s.Equal("# bindPort = 7000", SetTOML("bindPort = 7000", "bindPort", ""))
s.Equal("# bindPort = 7000", SetTOML("# bindPort = 7000", "bindPort", ""))
// 键不存在时不应插入空值
s.Equal("bindPort = 7000", SetTOML("bindPort = 7000", "auth.token", ""))
}
func (s *TOMLTestSuite) TestSetReenablesCommented() {
s.Equal("maxPortsPerClient = 5", SetTOML("# maxPortsPerClient = 0", "maxPortsPerClient", 5))
}
func (s *TOMLTestSuite) TestSetBool() {
s.Equal("bindPort = 7000\ntransport.tls.force = true", SetTOML("bindPort = 7000", "transport.tls.force", true))
}
func (s *TOMLTestSuite) TestSetDoesNotTouchProxyBlock() {
conf := "serverAddr = \"1.1.1.1\"\n\n[[proxies]]\n# 手写的隧道\nname = \"ssh\"\nserverPort = 22\n"
s.Equal("serverAddr = \"2.2.2.2\"\n\n[[proxies]]\n# 手写的隧道\nname = \"ssh\"\nserverPort = 22\n", SetTOML(conf, "serverAddr", "2.2.2.2"))
// serverPort 只存在于 proxies 表内,不应被改写,而是插到首个表头前
s.Equal("serverAddr = \"1.1.1.1\"\n\nserverPort = 7000\n[[proxies]]\n# 手写的隧道\nname = \"ssh\"\nserverPort = 22\n", SetTOML(conf, "serverPort", 7000))
}
+50
View File
@@ -0,0 +1,50 @@
package confval
import (
"strings"
"github.com/spf13/cast"
)
// GetYAML 读取 YAML 配置项,优先平铺键(安装脚本用 sed 生成的格式),回退到嵌套键
func GetYAML(cfg map[string]any, key string) string {
if val, ok := cfg[key]; ok {
return cast.ToString(val)
}
prefix, rest, nested := strings.Cut(key, ".")
val, ok := cfg[prefix]
if !ok {
return ""
}
if !nested {
return cast.ToString(val)
}
child, ok := val.(map[string]any)
if !ok {
return ""
}
return GetYAML(child, rest)
}
// SetYAML 以平铺键写入 YAML 配置项,同时清理可能残留的同名嵌套键
func SetYAML(cfg map[string]any, key string, value string) {
if value == "" {
return
}
cfg[key] = value
prefix, rest, nested := strings.Cut(key, ".")
if !nested {
return
}
child, ok := cfg[prefix].(map[string]any)
if !ok {
return
}
delete(child, rest)
if len(child) == 0 {
delete(cfg, prefix)
}
}
+14 -29
View File
@@ -8,9 +8,11 @@ import (
"github.com/go-chi/chi/v5"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct{}
@@ -26,34 +28,17 @@ func (s *App) Route(r chi.Router) {
r.Post("/settings", s.UpdateSettings)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read("/etc/docker/daemon.json")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
func (s *App) Status() string {
ok, _ := systemctl.Status("docker")
return types.AggregateAppStatus(ok)
}
service.Success(w, config)
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
common.ServeConfig(w, "/etc/docker/daemon.json")
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write("/etc/docker/daemon.json", req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("docker"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
common.SaveConfig(w, r, "/etc/docker/daemon.json", "docker")
}
// GetSettings 获取 Docker 设置
@@ -103,8 +88,8 @@ func (s *App) GetSettings(w http.ResponseWriter, r *http.Request) {
// 从 exec-opts 中提取 cgroup-driver
for _, opt := range daemonConfig.ExecOpts {
if strings.HasPrefix(opt, "native.cgroupdriver=") {
settings.CgroupDriver = strings.TrimPrefix(opt, "native.cgroupdriver=")
if after, ok := strings.CutPrefix(opt, "native.cgroupdriver="); ok {
settings.CgroupDriver = after
break
}
}
+16 -20
View File
@@ -1,9 +1,5 @@
package docker
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// LogOpts 日志配置选项
type LogOpts struct {
MaxSize string `json:"max-size,omitempty"` // 日志文件最大大小,如 "10m"
@@ -12,22 +8,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,omitempty"` // 日志配置选项
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 更新设置请求
+247
View File
@@ -0,0 +1,247 @@
package elasticsearch
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"time"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/spf13/cast"
"go.yaml.in/yaml/v4"
"resty.dev/v3"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
r.Get("/load", s.Load)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("elasticsearch")
return types.AggregateAppStatus(ok)
}
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, err := systemctl.Status("elasticsearch")
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get elasticsearch status: %v", err))
return
}
if !status {
service.Success(w, []types.NV{})
return
}
port := s.getPort()
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.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
var health struct {
ClusterName string `json:"cluster_name"`
Status string `json:"status"`
NumberOfNodes int `json:"number_of_nodes"`
NumberOfDataNodes int `json:"number_of_data_nodes"`
ActiveShards int `json:"active_shards"`
ActivePrimaryShards int `json:"active_primary_shards"`
RelocatingShards int `json:"relocating_shards"`
UnassignedShards int `json:"unassigned_shards"`
}
if err = json.Unmarshal(resp.Bytes(), &health); err != nil {
service.Success(w, []types.NV{})
return
}
data := []types.NV{
{Name: s.t.Get("Cluster Name"), Value: health.ClusterName},
{Name: s.t.Get("Cluster Status"), Value: health.Status},
{Name: s.t.Get("Number of Nodes"), Value: cast.ToString(health.NumberOfNodes)},
{Name: s.t.Get("Number of Data Nodes"), Value: cast.ToString(health.NumberOfDataNodes)},
{Name: s.t.Get("Active Shards"), Value: cast.ToString(health.ActiveShards)},
{Name: s.t.Get("Active Primary Shards"), Value: cast.ToString(health.ActivePrimaryShards)},
{Name: s.t.Get("Relocating Shards"), Value: cast.ToString(health.RelocatingShards)},
{Name: s.t.Get("Unassigned Shards"), Value: cast.ToString(health.UnassignedShards)},
}
service.Success(w, data)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
common.ServeConfig(w, s.configPath())
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
common.SaveConfig(w, r, s.configPath(), "elasticsearch")
}
// GetConfigTune 获取 ElasticSearch 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
raw, _ := io.Read(s.configPath())
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg == nil {
cfg = make(map[string]any)
}
// ES 9.x 不再在 jvm.options 中设置堆内存,改为 jvm.options.d/heap.options
heapRaw, _ := io.Read(s.jvmHeapOptionsPath())
heapInit, heapMax := s.parseJVMHeap(heapRaw)
// 兼容旧版本:如果 heap.options 没有,尝试从 jvm.options 读取
if heapInit == "" && heapMax == "" {
jvmRaw, _ := io.Read(s.jvmOptionsPath())
heapInit, heapMax = s.parseJVMHeap(jvmRaw)
}
tune := ConfigTune{
ClusterName: confval.GetYAML(cfg, "cluster.name"),
NodeName: confval.GetYAML(cfg, "node.name"),
NetworkHost: confval.GetYAML(cfg, "network.host"),
HTTPPort: confval.GetYAML(cfg, "http.port"),
DiscoveryType: confval.GetYAML(cfg, "discovery.type"),
PathData: confval.GetYAML(cfg, "path.data"),
PathLogs: confval.GetYAML(cfg, "path.logs"),
HeapInitSize: heapInit,
HeapMaxSize: heapMax,
}
service.Success(w, tune)
}
// UpdateConfigTune 更新 ElasticSearch 配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 更新 YAML 配置
raw, _ := io.Read(s.configPath())
var cfg map[string]any
if err = yaml.Unmarshal([]byte(raw), &cfg); err != nil {
cfg = make(map[string]any)
}
confval.SetYAML(cfg, "cluster.name", req.ClusterName)
confval.SetYAML(cfg, "node.name", req.NodeName)
confval.SetYAML(cfg, "network.host", req.NetworkHost)
confval.SetYAML(cfg, "http.port", req.HTTPPort)
confval.SetYAML(cfg, "discovery.type", req.DiscoveryType)
confval.SetYAML(cfg, "path.data", req.PathData)
confval.SetYAML(cfg, "path.logs", req.PathLogs)
data, err := yaml.Marshal(cfg)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(s.configPath(), string(data), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 更新 JVM 堆内存(写入 jvm.options.d/heap.options
if req.HeapInitSize != "" || req.HeapMaxSize != "" {
heapRaw, _ := io.Read(s.jvmHeapOptionsPath())
heapRaw = s.setJVMHeap(heapRaw, req.HeapInitSize, req.HeapMaxSize)
if err = io.Write(s.jvmHeapOptionsPath(), heapRaw, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
if err = systemctl.Restart("elasticsearch"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// configPath 返回配置文件路径
func (s *App) configPath() string {
return app.Root + "/server/elasticsearch/config/elasticsearch.yml"
}
// jvmOptionsPath 返回 JVM 选项文件路径
func (s *App) jvmOptionsPath() string {
return app.Root + "/server/elasticsearch/config/jvm.options"
}
// jvmHeapOptionsPath 返回 JVM 堆内存配置文件路径(ES 9.x 推荐方式)
func (s *App) jvmHeapOptionsPath() string {
return app.Root + "/server/elasticsearch/config/jvm.options.d/heap.options"
}
// getPort 从配置中获取 HTTP 端口
func (s *App) getPort() string {
raw, _ := io.Read(s.configPath())
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg != nil {
if v := confval.GetYAML(cfg, "http.port"); v != "" {
return v
}
}
return "9200"
}
// parseJVMHeap 从 jvm.options 中提取堆内存配置
func (s *App) parseJVMHeap(content string) (initSize, maxSize string) {
reInit := regexp.MustCompile(`(?m)^-Xms(\S+)`)
reMax := regexp.MustCompile(`(?m)^-Xmx(\S+)`)
if m := reInit.FindStringSubmatch(content); len(m) == 2 {
initSize = m[1]
}
if m := reMax.FindStringSubmatch(content); len(m) == 2 {
maxSize = m[1]
}
return
}
// setJVMHeap 替换 jvm.options 中的堆内存配置
func (s *App) setJVMHeap(content string, initSize, maxSize string) string {
if initSize != "" {
re := regexp.MustCompile(`(?m)^-Xms\S+`)
if re.MatchString(content) {
content = re.ReplaceAllString(content, "-Xms"+initSize)
} else {
content += "\n-Xms" + initSize
}
}
if maxSize != "" {
re := regexp.MustCompile(`(?m)^-Xmx\S+`)
if re.MatchString(content) {
content = re.ReplaceAllString(content, "-Xmx"+maxSize)
} else {
content += "\n-Xmx" + maxSize
}
}
return content
}
+17
View File
@@ -0,0 +1,17 @@
package elasticsearch
// ConfigTune ElasticSearch 配置调整
type ConfigTune struct {
// 集群
ClusterName string `form:"cluster_name" json:"cluster_name"`
NodeName string `form:"node_name" json:"node_name"`
NetworkHost string `form:"network_host" json:"network_host"`
HTTPPort string `form:"http_port" json:"http_port"`
DiscoveryType string `form:"discovery_type" json:"discovery_type"`
// 路径
PathData string `form:"path_data" json:"path_data"`
PathLogs string `form:"path_logs" json:"path_logs"`
// JVM
HeapInitSize string `form:"heap_init_size" json:"heap_init_size"`
HeapMaxSize string `form:"heap_max_size" json:"heap_max_size"`
}
+143 -197
View File
@@ -1,22 +1,25 @@
package fail2ban
import (
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/utils/str"
"github.com/libtnb/chix/v2"
"github.com/samber/lo"
"github.com/spf13/cast"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/service"
"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"
webserver "github.com/acepanel/panel/v3/pkg/webserver/types"
)
type App struct {
@@ -24,58 +27,37 @@ type App struct {
websiteRepo biz.WebsiteRepo
}
func NewApp(t *gotext.Locale, website biz.WebsiteRepo) *App {
func NewApp(t *gotext.Locale, websiteRepo biz.WebsiteRepo) *App {
return &App{
t: t,
websiteRepo: website,
websiteRepo: websiteRepo,
}
}
func (s *App) Route(r chi.Router) {
r.Get("/jails", s.List)
r.Post("/jails", s.Create)
r.Delete("/jails", s.Delete)
r.Get("/jails/{name}", s.BanList)
r.Post("/jails/{name}", s.Update)
r.Delete("/jails/{name}", s.Delete)
r.Get("/jails/{name}/ban", s.BanList)
r.Post("/unban", s.Unban)
r.Post("/white_list", s.SetWhiteList)
r.Get("/white_list", s.GetWhiteList)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("fail2ban")
return types.AggregateAppStatus(ok)
}
// List 所有规则
func (s *App) List(w http.ResponseWriter, r *http.Request) {
raw, err := io.Read("/etc/fail2ban/jail.local")
jails, err := listJails()
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
jailList := regexp.MustCompile(`\[(.*?)]`).FindAllStringSubmatch(raw, -1)
jails := make([]Jail, 0)
for i, jail := range jailList {
if i == 0 {
continue
}
jailName := jail[1]
jailRaw := str.Cut(raw, "# "+jailName+"-START", "# "+jailName+"-END")
if len(jailRaw) == 0 {
continue
}
jailEnabled := strings.Contains(jailRaw, "enabled = true")
jailMaxRetry := regexp.MustCompile(`maxretry = (.*)`).FindStringSubmatch(jailRaw)
jailFindTime := regexp.MustCompile(`findtime = (.*)`).FindStringSubmatch(jailRaw)
jailBanTime := regexp.MustCompile(`bantime = (.*)`).FindStringSubmatch(jailRaw)
jails = append(jails, Jail{
Name: jailName,
Enabled: jailEnabled,
MaxRetry: cast.ToInt(jailMaxRetry[1]),
FindTime: cast.ToInt(jailFindTime[1]),
BanTime: cast.ToInt(jailBanTime[1]),
})
}
paged, total := service.Paginate(r, jails)
service.Success(w, chix.M{
@@ -91,85 +73,39 @@ func (s *App) Create(w http.ResponseWriter, r *http.Request) {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
jailName := req.Name
jailType := req.Type
jailMaxRetry := cast.ToString(req.MaxRetry)
jailFindTime := cast.ToString(req.FindTime)
jailBanTime := cast.ToString(req.BanTime)
jailWebsiteName := req.WebsiteName
jailWebsiteMode := req.WebsiteMode
jailWebsitePath := req.WebsitePath
raw, err := io.Read("/etc/fail2ban/jail.local")
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if (strings.Contains(raw, "["+jailName+"]") && jailType == "service") || (strings.Contains(raw, "["+jailWebsiteName+"]"+"-cc") && jailType == "website" && jailWebsiteMode == "cc") || (strings.Contains(raw, "["+jailWebsiteName+"]"+"-path") && jailType == "website" && jailWebsiteMode == "path") {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("rule already exists"))
return
jail := Jail{
Enabled: true,
MaxRetry: req.MaxRetry,
FindTime: req.FindTime,
BanTime: req.BanTime,
}
switch jailType {
switch req.Type {
case "website":
website, err := s.websiteRepo.GetByName(jailWebsiteName)
website, err := s.websiteRepo.GetByName(req.WebsiteName)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
var ports string
for _, listen := range website.Listens {
if port, err := cast.ToIntE(listen.Address); err == nil {
ports += fmt.Sprintf("%d", port) + ","
}
}
ports = strings.TrimSuffix(ports, ",")
rule := `
# ` + jailWebsiteName + `-` + jailWebsiteMode + `-START
[` + jailWebsiteName + `-` + jailWebsiteMode + `]
enabled = true
filter = haozi-` + jailWebsiteName + `-` + jailWebsiteMode + `
port = ` + ports + `
maxretry = ` + jailMaxRetry + `
findtime = ` + jailFindTime + `
bantime = ` + jailBanTime + `
logpath = ` + app.Root + `/sites/` + website.Name + `/log/access.log
# ` + jailWebsiteName + `-` + jailWebsiteMode + `-END
`
raw += rule
if err = io.Write("/etc/fail2ban/jail.local", raw, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
ports := lo.FilterMap(website.Listens, func(listen webserver.Listen, _ int) (string, bool) {
port, err := cast.ToIntE(listen.Address)
return strconv.Itoa(port), err == nil
})
var filter string
if jailWebsiteMode == "cc" {
filter = `
[Definition]
failregex = ^<HOST>\s-.*HTTP/.*$
ignoreregex =
`
} else {
filter = `
[Definition]
failregex = ^<HOST>\s-.*\s` + jailWebsitePath + `.*HTTP/.*$
ignoreregex =
`
}
if err = io.Write("/etc/fail2ban/filter.d/haozi-"+jailWebsiteName+"-"+jailWebsiteMode+".conf", filter, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
jail.Name = req.WebsiteName + "-" + req.WebsiteMode
jail.Filter = panelFilterPrefix + jail.Name
jail.Port = strings.Join(ports, ",")
jail.LogPath = app.Root + "/sites/" + website.Name + "/log/access.log"
case "service":
var filter string
var port string
var filter, port string
var err error
switch jailName {
switch req.Name {
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)
@@ -185,104 +121,132 @@ ignoreregex =
return
}
rule := `
# ` + jailName + `-START
[` + jailName + `]
enabled = true
filter = ` + filter + `
port = ` + port + `
maxretry = ` + jailMaxRetry + `
findtime = ` + jailFindTime + `
bantime = ` + jailBanTime + `
# ` + jailName + `-END
`
raw += rule
if err := io.Write("/etc/fail2ban/jail.local", raw, 0644); err != nil {
jail.Name = req.Name
jail.Filter = filter
jail.Port = port
}
if io.Exists(jailPath(jail.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("rule already exists"))
return
}
// 网站规则的过滤器由面板生成,服务规则复用 fail2ban 自带的
if req.Type == "website" {
failRegex := `^<HOST>\s-.*HTTP/.*$`
if req.WebsiteMode == "path" {
failRegex = `^<HOST>\s-.*\s` + req.WebsitePath + `.*HTTP/.*$`
}
filter := "[Definition]\nfailregex = " + failRegex + "\nignoreregex =\n"
if err = io.Write(filterPath(jail.Filter), filter, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
if _, err = shell.Execf("fail2ban-client reload"); err != nil {
if err = writeJail(jail); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
s.reload(w)
}
// Delete 删除规则
func (s *App) Delete(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Delete](r)
// Update 修改规则
func (s *App) Update(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Update](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
raw, err := io.Read("/etc/fail2ban/jail.local")
jail, err := readJail(req.Name)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if !strings.Contains(raw, "["+req.Name+"]") {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("rule not found"))
return
}
rule := str.Cut(raw, "# "+req.Name+"-START", "# "+req.Name+"-END")
raw = strings.ReplaceAll(raw, "\n# "+req.Name+"-START"+rule+"# "+req.Name+"-END", "")
raw = strings.TrimSpace(raw)
if err := io.Write("/etc/fail2ban/jail.local", raw, 0644); err != nil {
jail.Enabled = req.Enabled
jail.MaxRetry = req.MaxRetry
jail.FindTime = req.FindTime
jail.BanTime = req.BanTime
if err = writeJail(jail); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if _, err := shell.Execf("fail2ban-client reload"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
s.reload(w)
}
// BanList 获取封禁列表
func (s *App) BanList(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[BanList](r)
// Delete 删除规则
func (s *App) Delete(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[JailName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
currentlyBan, err := shell.Execf(`fail2ban-client status %s | grep "Currently banned" | awk '{print $4}'`, req.Name)
jail, err := readJail(req.Name)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get current banned list"))
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("rule not found"))
return
}
totalBan, err := shell.Execf(`fail2ban-client status %s | grep "Total banned" | awk '{print $4}'`, req.Name)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get total banned list"))
return
}
bannedIp, err := shell.Execf(`fail2ban-client status %s | grep "Banned IP list" | sed 's/.*Banned IP list:[[:space:]]*//'`, req.Name)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get banned ip list"))
return
}
bannedIpList := strings.Split(bannedIp, " ")
var list []map[string]string
for _, ip := range bannedIpList {
if len(ip) > 0 {
list = append(list, map[string]string{
"name": req.Name,
"ip": ip,
})
if err = io.Remove(jailPath(jail.Name)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 面板生成的过滤器随规则一起删除,fail2ban 自带的保留
if strings.HasPrefix(jail.Filter, panelFilterPrefix) {
if err = io.Remove(filterPath(jail.Filter)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
if list == nil {
list = []map[string]string{}
s.reload(w)
}
// BanList 获取封禁列表
func (s *App) BanList(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[JailName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
out, err := shell.Execf("fail2ban-client status %s", req.Name)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get the status of rule %s: %v", req.Name, err))
return
}
// 输出形如 " |- Currently banned: 1",行首有树状前缀,按首个冒号切分后比对尾部
var currentlyBan, totalBan, bannedIP string
for line := range strings.SplitSeq(out, "\n") {
label, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch {
case strings.HasSuffix(label, "Currently banned"):
currentlyBan = strings.TrimSpace(value)
case strings.HasSuffix(label, "Total banned"):
totalBan = strings.TrimSpace(value)
case strings.HasSuffix(label, "Banned IP list"):
bannedIP = strings.TrimSpace(value)
}
}
list := lo.Map(strings.Fields(bannedIP), func(ip string, _ int) map[string]string {
return map[string]string{
"name": req.Name,
"ip": ip,
}
})
service.Success(w, chix.M{
"currently_ban": currentlyBan,
"total_ban": totalBan,
@@ -314,45 +278,27 @@ func (s *App) SetWhiteList(w http.ResponseWriter, r *http.Request) {
return
}
raw, err := io.Read("/etc/fail2ban/jail.local")
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 正则替换
reg := regexp.MustCompile(`ignoreip\s*=\s*.*\n`)
if reg.MatchString(raw) {
raw = reg.ReplaceAllString(raw, "ignoreip = "+req.IP+"\n")
} else {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to parse the ignoreip of fail2ban"))
return
}
if err = io.Write("/etc/fail2ban/jail.local", raw, 0644); err != nil {
raw, _ := io.Read(jailLocal)
if err = io.Write(jailLocal, confval.SectionINI.SetIn(raw, "DEFAULT", "ignoreip", req.IP), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if _, err = shell.Execf("fail2ban-client reload"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
s.reload(w)
}
// GetWhiteList 获取白名单
func (s *App) GetWhiteList(w http.ResponseWriter, r *http.Request) {
raw, err := io.Read("/etc/fail2ban/jail.local")
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
reg := regexp.MustCompile(`ignoreip\s*=\s*(.*)\n`)
if reg.MatchString(raw) {
ignoreIp := reg.FindStringSubmatch(raw)[1]
service.Success(w, ignoreIp)
} else {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to parse the ignoreip of fail2ban"))
return
}
raw, _ := io.Read(jailLocal)
service.Success(w, confval.SectionINI.GetIn(raw, "DEFAULT", "ignoreip"))
}
func (s *App) reload(w http.ResponseWriter) {
if _, err := shell.Execf("fail2ban-client reload"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
+80
View File
@@ -0,0 +1,80 @@
package fail2ban
import (
"fmt"
"path/filepath"
"strings"
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/pkg/io"
)
const (
// jailLocal 只承载 [DEFAULT] 全局项,规则一律放 jail.d
jailLocal = "/etc/fail2ban/jail.local"
// jailDDir fail2ban 原生的 drop-in 目录,.local 后缀的读取优先级最高
jailDDir = "/etc/fail2ban/jail.d"
filterDir = "/etc/fail2ban/filter.d"
// panelFilterPrefix 面板为网站规则生成的过滤器前缀,用于区分 fail2ban 自带的过滤器
panelFilterPrefix = "haozi-"
)
func jailPath(name string) string {
return filepath.Join(jailDDir, name+".local")
}
func filterPath(filter string) string {
return filepath.Join(filterDir, filter+".conf")
}
func readJail(name string) (Jail, error) {
raw, err := io.Read(jailPath(name))
if err != nil {
return Jail{}, err
}
return Jail{
Name: name,
Enabled: cast.ToBool(confval.SectionINI.GetIn(raw, name, "enabled")),
MaxRetry: cast.ToInt(confval.SectionINI.GetIn(raw, name, "maxretry")),
FindTime: cast.ToInt(confval.SectionINI.GetIn(raw, name, "findtime")),
BanTime: cast.ToInt(confval.SectionINI.GetIn(raw, name, "bantime")),
Filter: confval.SectionINI.GetIn(raw, name, "filter"),
Port: confval.SectionINI.GetIn(raw, name, "port"),
LogPath: confval.SectionINI.GetIn(raw, name, "logpath"),
}, nil
}
func listJails() ([]Jail, error) {
// Glob 在目录不存在时返回空,无需额外判断
paths, err := filepath.Glob(jailPath("*"))
if err != nil {
return nil, err
}
jails := make([]Jail, 0, len(paths))
for _, path := range paths {
jail, err := readJail(strings.TrimSuffix(filepath.Base(path), ".local"))
if err != nil {
return nil, err
}
jails = append(jails, jail)
}
return jails, nil
}
func writeJail(jail Jail) error {
config := fmt.Sprintf(
"[%s]\nenabled = %t\nfilter = %s\nport = %s\nmaxretry = %d\nfindtime = %d\nbantime = %d\n",
jail.Name, jail.Enabled, jail.Filter, jail.Port, jail.MaxRetry, jail.FindTime, jail.BanTime,
)
if jail.LogPath != "" {
config += "backend = auto\nlogpath = " + jail.LogPath + "\n"
}
return io.Write(jailPath(jail.Name), config, 0644)
}
+18 -13
View File
@@ -1,27 +1,32 @@
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"`
Name string `json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
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"`
}
type Delete struct {
Name string `json:"name" validate:"required"`
type JailName struct {
Name string `form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
}
type BanList struct {
Name string `json:"name" validate:"required"`
// Update 只允许改这几项,过滤器、端口与日志路径随规则文件原样保留
type Update struct {
Name string `form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
Enabled bool `json:"enabled"`
MaxRetry int `json:"max_retry" validate:"required && min:1"`
FindTime int `json:"find_time" validate:"required && min:1"`
BanTime int `json:"ban_time" validate:"required && min:1"`
}
type Unban struct {
Name string `json:"name" validate:"required"`
IP string `json:"ip" validate:"required"`
Name string `form:"name" json:"name" validate:"required"`
IP string `json:"ip" validate:"required && ip"`
}
type SetWhiteList struct {
+5
View File
@@ -6,4 +6,9 @@ type Jail struct {
MaxRetry int `json:"max_retry"`
FindTime int `json:"find_time"`
BanTime int `json:"ban_time"`
// 以下几项在新增时由面板推导,之后随规则文件原样保留
Filter string `json:"filter"`
Port string `json:"port"`
LogPath string `json:"log_path"`
}
+314 -17
View File
@@ -5,17 +5,23 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix/v2"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct{}
type App struct {
t *gotext.Locale
}
func NewApp() *App {
return &App{}
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}
}
func (s *App) Route(r chi.Router) {
@@ -23,6 +29,26 @@ func (s *App) Route(r chi.Router) {
r.Post("/config", s.UpdateConfig)
r.Get("/user", s.GetUser)
r.Post("/user", s.UpdateUser)
r.Get("/server", s.GetServer)
r.Post("/server", s.UpdateServer)
r.Get("/client", s.GetClient)
r.Post("/client", s.UpdateClient)
r.Get("/proxies", s.Proxies)
r.Post("/proxies", s.CreateProxy)
r.Get("/proxies/{name}", s.GetProxy)
r.Post("/proxies/{name}", s.UpdateProxy)
r.Delete("/proxies/{name}", s.DeleteProxy)
r.Get("/visitors", s.Visitors)
r.Post("/visitors", s.CreateVisitor)
r.Get("/visitors/{name}", s.GetVisitor)
r.Post("/visitors/{name}", s.UpdateVisitor)
r.Delete("/visitors/{name}", s.DeleteVisitor)
}
func (s *App) Status() string {
frps, _ := systemctl.Status("frps")
frpc, _ := systemctl.Status("frpc")
return types.AggregateAppStatus(frps, frpc)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
@@ -32,7 +58,7 @@ func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
return
}
config, err := io.Read(fmt.Sprintf("%s/server/frp/%s.toml", app.Root, req.Name))
config, err := io.Read(confPath(req.Name))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -48,7 +74,7 @@ func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err = io.Write(fmt.Sprintf("%s/server/frp/%s.toml", app.Root, req.Name), req.Config, 0644); err != nil {
if err = io.Write(confPath(req.Name), req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -107,18 +133,19 @@ func (s *App) UpdateUser(w http.ResponseWriter, r *http.Request) {
hasGroup := groupRegex.MatchString(content)
// 替换或添加 User 和 Group 配置
if hasUser && hasGroup {
switch {
case hasUser && hasGroup:
// 两者都存在,分别替换
content = userRegex.ReplaceAllString(content, fmt.Sprintf("User=%s", req.User))
content = groupRegex.ReplaceAllString(content, fmt.Sprintf("Group=%s", req.Group))
} else if hasUser && !hasGroup {
content = userRegex.ReplaceAllString(content, "User="+req.User)
content = groupRegex.ReplaceAllString(content, "Group="+req.Group)
case hasUser:
// 只有 User,替换 User 并添加 Group
content = userRegex.ReplaceAllString(content, fmt.Sprintf("User=%s\nGroup=%s", req.User, req.Group))
} else if !hasUser && hasGroup {
case hasGroup:
// 只有 Group,添加 User 并替换 Group
content = serviceRegex.ReplaceAllString(content, fmt.Sprintf("[Service]\nUser=%s", req.User))
content = groupRegex.ReplaceAllString(content, fmt.Sprintf("Group=%s", req.Group))
} else {
content = serviceRegex.ReplaceAllString(content, "[Service]\nUser="+req.User)
content = groupRegex.ReplaceAllString(content, "Group="+req.Group)
default:
// 两者都不存在,在 [Service] 后添加两者
content = serviceRegex.ReplaceAllString(content, fmt.Sprintf("[Service]\nUser=%s\nGroup=%s", req.User, req.Group))
}
@@ -139,3 +166,273 @@ func (s *App) UpdateUser(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
// GetServer 获取 frps 可视化参数
func (s *App) GetServer(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(confPath("frps"))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
tune := new(ServerTune)
readTune(config, serverFields(tune))
service.Success(w, tune)
}
// UpdateServer 更新 frps 可视化参数
func (s *App) UpdateServer(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ServerTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
path := confPath("frps")
config, err := io.Read(path)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(path, writeTune(config, serverFields(req)), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("frps"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// GetClient 获取 frpc 可视化公共参数
func (s *App) GetClient(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(confPath("frpc"))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
tune := new(ClientTune)
readTune(config, clientFields(tune))
service.Success(w, tune)
}
// UpdateClient 更新 frpc 可视化公共参数
func (s *App) UpdateClient(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ClientTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
path := confPath("frpc")
config, err := io.Read(path)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(path, writeTune(config, clientFields(req)), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("frpc"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// Proxies 代理列表
func (s *App) Proxies(w http.ResponseWriter, r *http.Request) {
confD, err := listConfD()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
paged, total := service.Paginate(r, confD.Proxies)
service.Success(w, chix.M{
"total": total,
"items": paged,
})
}
// CreateProxy 新增代理
func (s *App) CreateProxy(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Proxy](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if io.Exists(itemPath(proxyPrefix, req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("proxy %s already exists", req.Name))
return
}
s.save(w, proxyPrefix, req.Name, ConfD{Proxies: []Proxy{*req}})
}
// GetProxy 获取单个代理
func (s *App) GetProxy(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ItemName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
confD, err := readConfD(itemPath(proxyPrefix, req.Name))
if err != nil || len(confD.Proxies) == 0 {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("proxy %s does not exist", req.Name))
return
}
service.Success(w, confD.Proxies[0])
}
// UpdateProxy 更新代理
func (s *App) UpdateProxy(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Proxy](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if !io.Exists(itemPath(proxyPrefix, req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("proxy %s does not exist", req.Name))
return
}
s.save(w, proxyPrefix, req.Name, ConfD{Proxies: []Proxy{*req}})
}
// DeleteProxy 删除代理
func (s *App) DeleteProxy(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ItemName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Remove(itemPath(proxyPrefix, req.Name)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("frpc"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// Visitors 访问者列表
func (s *App) Visitors(w http.ResponseWriter, r *http.Request) {
confD, err := listConfD()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
paged, total := service.Paginate(r, confD.Visitors)
service.Success(w, chix.M{
"total": total,
"items": paged,
})
}
// CreateVisitor 新增访问者
func (s *App) CreateVisitor(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Visitor](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if io.Exists(itemPath(visitorPrefix, req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("visitor %s already exists", req.Name))
return
}
s.save(w, visitorPrefix, req.Name, ConfD{Visitors: []Visitor{*req}})
}
// GetVisitor 获取单个访问者
func (s *App) GetVisitor(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ItemName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
confD, err := readConfD(itemPath(visitorPrefix, req.Name))
if err != nil || len(confD.Visitors) == 0 {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("visitor %s does not exist", req.Name))
return
}
service.Success(w, confD.Visitors[0])
}
// UpdateVisitor 更新访问者
func (s *App) UpdateVisitor(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Visitor](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if !io.Exists(itemPath(visitorPrefix, req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("visitor %s does not exist", req.Name))
return
}
s.save(w, visitorPrefix, req.Name, ConfD{Visitors: []Visitor{*req}})
}
// DeleteVisitor 删除访问者
func (s *App) DeleteVisitor(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ItemName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Remove(itemPath(visitorPrefix, req.Name)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("frpc"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
func (s *App) save(w http.ResponseWriter, prefix, name string, confD ConfD) {
if err := writeConfD(prefix, name, confD); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err := systemctl.Restart("frpc"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
+77
View File
@@ -0,0 +1,77 @@
package frp
import (
"os"
"path/filepath"
"github.com/pelletier/go-toml/v2"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/pkg/io"
)
const (
proxyPrefix = "proxy"
visitorPrefix = "visitor"
)
func confPath(name string) string {
return filepath.Join(app.Root, "server", "frp", name+".toml")
}
func confDDir() string {
return filepath.Join(app.Root, "server", "frp", "conf.d")
}
func confDGlob() string {
return filepath.Join(confDDir(), "*.toml")
}
func itemPath(prefix, name string) string {
return filepath.Join(confDDir(), prefix+"-"+name+".toml")
}
func readConfD(path string) (ConfD, error) {
raw, err := io.Read(path)
if err != nil {
return ConfD{}, err
}
var confD ConfD
return confD, toml.Unmarshal([]byte(raw), &confD)
}
func listConfD() (ConfD, error) {
var all ConfD
// Glob 在目录不存在时返回空,无需额外判断
paths, err := filepath.Glob(confDGlob())
if err != nil {
return all, err
}
for _, path := range paths {
confD, err := readConfD(path)
if err != nil {
return all, err
}
all.Proxies = append(all.Proxies, confD.Proxies...)
all.Visitors = append(all.Visitors, confD.Visitors...)
}
return all, nil
}
func writeConfD(prefix, name string, confD ConfD) error {
if err := os.MkdirAll(confDDir(), 0755); err != nil {
return err
}
raw, err := toml.Marshal(confD)
if err != nil {
return err
}
return io.Write(itemPath(prefix, name), string(raw), 0644)
}
+10 -5
View File
@@ -1,16 +1,21 @@
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_-]+$\""`
}
// ItemName 代理或访问者的名称,来自路径参数
type ItemName struct {
Name string `form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
}
+139
View File
@@ -0,0 +1,139 @@
package frp
import (
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/apps/confval"
)
type tuneKind uint8
const (
tuneString tuneKind = iota
tuneInt
tuneBool
)
// tuneField 可视化参数与 TOML 键的对应关系,表单一律用字符串收值,kind 决定写回时的字面量类型
type tuneField struct {
key string
value *string
kind tuneKind
}
// literal 空串表示注释掉该项
func (f tuneField) literal() any {
if *f.value == "" {
return ""
}
switch f.kind {
case tuneInt:
return cast.ToInt(*f.value)
case tuneBool:
return cast.ToBool(*f.value)
default:
return *f.value
}
}
func readTune(config string, fields []tuneField) {
for _, f := range fields {
*f.value = confval.GetTOML(config, f.key)
}
}
func writeTune(config string, fields []tuneField) string {
for _, f := range fields {
config = confval.SetTOML(config, f.key, f.literal())
}
return config
}
func serverFields(t *ServerTune) []tuneField {
return []tuneField{
{"bindAddr", &t.BindAddr, tuneString},
{"bindPort", &t.BindPort, tuneInt},
{"kcpBindPort", &t.KCPBindPort, tuneInt},
{"quicBindPort", &t.QUICBindPort, tuneInt},
{"proxyBindAddr", &t.ProxyBindAddr, tuneString},
{"subDomainHost", &t.SubDomainHost, tuneString},
{"maxPortsPerClient", &t.MaxPortsPerClient, tuneInt},
{"udpPacketSize", &t.UDPPacketSize, tuneInt},
{"detailedErrorsToClient", &t.DetailedErrorsToClient, tuneBool},
{"auth.method", &t.AuthMethod, tuneString},
{"auth.token", &t.AuthToken, tuneString},
{"vhostHTTPPort", &t.VhostHTTPPort, tuneInt},
{"vhostHTTPSPort", &t.VhostHTTPSPort, tuneInt},
{"vhostHTTPTimeout", &t.VhostHTTPTimeout, tuneInt},
{"tcpmuxHTTPConnectPort", &t.TCPMuxHTTPConnectPort, tuneInt},
{"custom404Page", &t.Custom404Page, tuneString},
{"webServer.addr", &t.WebServerAddr, tuneString},
{"webServer.port", &t.WebServerPort, tuneInt},
{"webServer.user", &t.WebServerUser, tuneString},
{"webServer.password", &t.WebServerPassword, tuneString},
{"webServer.pprofEnable", &t.WebServerPprofEnable, tuneBool},
{"enablePrometheus", &t.EnablePrometheus, tuneBool},
{"transport.maxPoolCount", &t.TransportMaxPoolCount, tuneInt},
{"transport.tcpMux", &t.TransportTCPMux, tuneBool},
{"transport.tcpMuxKeepaliveInterval", &t.TransportTCPMuxKeepaliveInterval, tuneInt},
{"transport.tcpKeepalive", &t.TransportTCPKeepalive, tuneInt},
{"transport.heartbeatTimeout", &t.TransportHeartbeatTimeout, tuneInt},
{"transport.tls.force", &t.TransportTLSForce, tuneBool},
{"transport.tls.certFile", &t.TransportTLSCertFile, tuneString},
{"transport.tls.keyFile", &t.TransportTLSKeyFile, tuneString},
{"transport.tls.trustedCaFile", &t.TransportTLSTrustedCaFile, tuneString},
{"log.to", &t.LogTo, tuneString},
{"log.level", &t.LogLevel, tuneString},
{"log.maxDays", &t.LogMaxDays, tuneInt},
{"log.disablePrintColor", &t.LogDisablePrintColor, tuneBool},
}
}
func clientFields(t *ClientTune) []tuneField {
return []tuneField{
{"user", &t.User, tuneString},
{"serverAddr", &t.ServerAddr, tuneString},
{"serverPort", &t.ServerPort, tuneInt},
{"loginFailExit", &t.LoginFailExit, tuneBool},
{"natHoleStunServer", &t.NatHoleStunServer, tuneString},
{"dnsServer", &t.DNSServer, tuneString},
{"udpPacketSize", &t.UDPPacketSize, tuneInt},
{"auth.method", &t.AuthMethod, tuneString},
{"auth.token", &t.AuthToken, tuneString},
{"transport.protocol", &t.TransportProtocol, tuneString},
{"transport.poolCount", &t.TransportPoolCount, tuneInt},
{"transport.tcpMux", &t.TransportTCPMux, tuneBool},
{"transport.tcpMuxKeepaliveInterval", &t.TransportTCPMuxKeepaliveInterval, tuneInt},
{"transport.dialServerTimeout", &t.TransportDialServerTimeout, tuneInt},
{"transport.dialServerKeepalive", &t.TransportDialServerKeepalive, tuneInt},
{"transport.heartbeatInterval", &t.TransportHeartbeatInterval, tuneInt},
{"transport.heartbeatTimeout", &t.TransportHeartbeatTimeout, tuneInt},
{"transport.connectServerLocalIP", &t.TransportConnectServerLocalIP, tuneString},
{"transport.proxyURL", &t.TransportProxyURL, tuneString},
{"transport.tls.enable", &t.TransportTLSEnable, tuneBool},
{"transport.tls.certFile", &t.TransportTLSCertFile, tuneString},
{"transport.tls.keyFile", &t.TransportTLSKeyFile, tuneString},
{"transport.tls.trustedCaFile", &t.TransportTLSTrustedCaFile, tuneString},
{"transport.tls.serverName", &t.TransportTLSServerName, tuneString},
{"webServer.addr", &t.WebServerAddr, tuneString},
{"webServer.port", &t.WebServerPort, tuneInt},
{"webServer.user", &t.WebServerUser, tuneString},
{"webServer.password", &t.WebServerPassword, tuneString},
{"webServer.pprofEnable", &t.WebServerPprofEnable, tuneBool},
{"log.to", &t.LogTo, tuneString},
{"log.level", &t.LogLevel, tuneString},
{"log.maxDays", &t.LogMaxDays, tuneInt},
{"log.disablePrintColor", &t.LogDisablePrintColor, tuneBool},
}
}
+213
View File
@@ -14,3 +14,216 @@ type UserInfo struct {
User string `json:"user"`
Group string `json:"group"`
}
// ConfD conf.d 目录下的配置文件,只承载代理与访问者
type ConfD struct {
Proxies []Proxy `toml:"proxies,omitempty"`
Visitors []Visitor `toml:"visitors,omitempty"`
}
// Proxy 代理配置,字段对齐 frp pkg/config/v1 的 ProxyConfigurer 实现
type Proxy struct {
Name string `toml:"name" form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
Type string `toml:"type" json:"type" validate:"required && in:tcp,udp,http,https,tcpmux,stcp,sudp,xtcp"`
Enabled *bool `toml:"enabled,omitempty" json:"enabled"`
LocalIP string `toml:"localIP,omitempty" json:"local_ip"`
LocalPort int `toml:"localPort,omitempty" json:"local_port"`
// tcp、udp
RemotePort int `toml:"remotePort,omitempty" json:"remote_port"`
// http、https、tcpmux
CustomDomains []string `toml:"customDomains,omitempty" json:"custom_domains"`
Subdomain string `toml:"subdomain,omitempty" json:"subdomain"`
// http、tcpmux
HTTPUser string `toml:"httpUser,omitempty" json:"http_user"`
HTTPPassword string `toml:"httpPassword,omitempty" json:"http_password"`
RouteByHTTPUser string `toml:"routeByHTTPUser,omitempty" json:"route_by_http_user"`
// http
Locations []string `toml:"locations,omitempty" json:"locations"`
HostHeaderRewrite string `toml:"hostHeaderRewrite,omitempty" json:"host_header_rewrite"`
RequestHeaders *HeaderOperations `toml:"requestHeaders,omitempty" json:"request_headers"`
ResponseHeaders *HeaderOperations `toml:"responseHeaders,omitempty" json:"response_headers"`
// tcpmux
Multiplexer string `toml:"multiplexer,omitempty" json:"multiplexer"`
// stcp、sudp、xtcp
SecretKey string `toml:"secretKey,omitempty" json:"secret_key"`
AllowUsers []string `toml:"allowUsers,omitempty" json:"allow_users"`
Transport *ProxyTransport `toml:"transport,omitempty" json:"transport"`
LoadBalancer *LoadBalancer `toml:"loadBalancer,omitempty" json:"load_balancer"`
HealthCheck *HealthCheck `toml:"healthCheck,omitempty" json:"health_check"`
Plugin *Plugin `toml:"plugin,omitempty" json:"plugin"`
Metadatas map[string]string `toml:"metadatas,omitempty" json:"metadatas"`
Annotations map[string]string `toml:"annotations,omitempty" json:"annotations"`
}
// Visitor 访问者配置,对应 stcp、sudp、xtcp 的客户端一侧
type Visitor struct {
Name string `toml:"name" form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
Type string `toml:"type" json:"type" validate:"required && in:stcp,sudp,xtcp"`
Enabled *bool `toml:"enabled,omitempty" json:"enabled"`
ServerUser string `toml:"serverUser,omitempty" json:"server_user"`
ServerName string `toml:"serverName,omitempty" json:"server_name"`
SecretKey string `toml:"secretKey,omitempty" json:"secret_key"`
BindAddr string `toml:"bindAddr,omitempty" json:"bind_addr"`
BindPort int `toml:"bindPort,omitempty" json:"bind_port"`
// xtcp
Protocol string `toml:"protocol,omitempty" json:"protocol"`
KeepTunnelOpen bool `toml:"keepTunnelOpen,omitempty" json:"keep_tunnel_open"`
MaxRetriesAnHour int `toml:"maxRetriesAnHour,omitempty" json:"max_retries_an_hour"`
MinRetryInterval int `toml:"minRetryInterval,omitempty" json:"min_retry_interval"`
FallbackTo string `toml:"fallbackTo,omitempty" json:"fallback_to"`
FallbackTimeoutMs int `toml:"fallbackTimeoutMs,omitempty" json:"fallback_timeout_ms"`
Transport *VisitorTransport `toml:"transport,omitempty" json:"transport"`
}
type ProxyTransport struct {
UseEncryption bool `toml:"useEncryption,omitempty" json:"use_encryption"`
UseCompression bool `toml:"useCompression,omitempty" json:"use_compression"`
BandwidthLimit string `toml:"bandwidthLimit,omitempty" json:"bandwidth_limit"`
BandwidthLimitMode string `toml:"bandwidthLimitMode,omitempty" json:"bandwidth_limit_mode"`
ProxyProtocolVersion string `toml:"proxyProtocolVersion,omitempty" json:"proxy_protocol_version"`
}
type VisitorTransport struct {
UseEncryption bool `toml:"useEncryption,omitempty" json:"use_encryption"`
UseCompression bool `toml:"useCompression,omitempty" json:"use_compression"`
}
type LoadBalancer struct {
Group string `toml:"group,omitempty" json:"group"`
GroupKey string `toml:"groupKey,omitempty" json:"group_key"`
}
type HealthCheck struct {
Type string `toml:"type" json:"type"`
TimeoutSeconds int `toml:"timeoutSeconds,omitempty" json:"timeout_seconds"`
MaxFailed int `toml:"maxFailed,omitempty" json:"max_failed"`
IntervalSeconds int `toml:"intervalSeconds,omitempty" json:"interval_seconds"`
Path string `toml:"path,omitempty" json:"path"`
}
type HeaderOperations struct {
Set map[string]string `toml:"set,omitempty" json:"set"`
}
// Plugin 客户端插件配置,各插件字段的并集
type Plugin struct {
Type string `toml:"type" json:"type"`
// unix_domain_socket
UnixPath string `toml:"unixPath,omitempty" json:"unix_path"`
// http_proxy、static_file
HTTPUser string `toml:"httpUser,omitempty" json:"http_user"`
HTTPPassword string `toml:"httpPassword,omitempty" json:"http_password"`
// socks5
Username string `toml:"username,omitempty" json:"username"`
Password string `toml:"password,omitempty" json:"password"`
// static_file
LocalPath string `toml:"localPath,omitempty" json:"local_path"`
StripPrefix string `toml:"stripPrefix,omitempty" json:"strip_prefix"`
// https2http、https2https、http2https、http2http、tls2raw
LocalAddr string `toml:"localAddr,omitempty" json:"local_addr"`
HostHeaderRewrite string `toml:"hostHeaderRewrite,omitempty" json:"host_header_rewrite"`
CrtPath string `toml:"crtPath,omitempty" json:"crt_path"`
KeyPath string `toml:"keyPath,omitempty" json:"key_path"`
}
// ServerTune frps 可视化参数
type ServerTune struct {
BindAddr string `form:"bind_addr" json:"bind_addr"`
BindPort string `form:"bind_port" json:"bind_port"`
KCPBindPort string `form:"kcp_bind_port" json:"kcp_bind_port"`
QUICBindPort string `form:"quic_bind_port" json:"quic_bind_port"`
ProxyBindAddr string `form:"proxy_bind_addr" json:"proxy_bind_addr"`
SubDomainHost string `form:"sub_domain_host" json:"sub_domain_host"`
MaxPortsPerClient string `form:"max_ports_per_client" json:"max_ports_per_client"`
UDPPacketSize string `form:"udp_packet_size" json:"udp_packet_size"`
DetailedErrorsToClient string `form:"detailed_errors_to_client" json:"detailed_errors_to_client"`
AuthMethod string `form:"auth_method" json:"auth_method"`
AuthToken string `form:"auth_token" json:"auth_token"`
VhostHTTPPort string `form:"vhost_http_port" json:"vhost_http_port"`
VhostHTTPSPort string `form:"vhost_https_port" json:"vhost_https_port"`
VhostHTTPTimeout string `form:"vhost_http_timeout" json:"vhost_http_timeout"`
TCPMuxHTTPConnectPort string `form:"tcpmux_http_connect_port" json:"tcpmux_http_connect_port"`
Custom404Page string `form:"custom_404_page" json:"custom_404_page"`
WebServerAddr string `form:"web_server_addr" json:"web_server_addr"`
WebServerPort string `form:"web_server_port" json:"web_server_port"`
WebServerUser string `form:"web_server_user" json:"web_server_user"`
WebServerPassword string `form:"web_server_password" json:"web_server_password"`
WebServerPprofEnable string `form:"web_server_pprof_enable" json:"web_server_pprof_enable"`
EnablePrometheus string `form:"enable_prometheus" json:"enable_prometheus"`
TransportMaxPoolCount string `form:"transport_max_pool_count" json:"transport_max_pool_count"`
TransportTCPMux string `form:"transport_tcp_mux" json:"transport_tcp_mux"`
TransportTCPMuxKeepaliveInterval string `form:"transport_tcp_mux_keepalive_interval" json:"transport_tcp_mux_keepalive_interval"`
TransportTCPKeepalive string `form:"transport_tcp_keepalive" json:"transport_tcp_keepalive"`
TransportHeartbeatTimeout string `form:"transport_heartbeat_timeout" json:"transport_heartbeat_timeout"`
TransportTLSForce string `form:"transport_tls_force" json:"transport_tls_force"`
TransportTLSCertFile string `form:"transport_tls_cert_file" json:"transport_tls_cert_file"`
TransportTLSKeyFile string `form:"transport_tls_key_file" json:"transport_tls_key_file"`
TransportTLSTrustedCaFile string `form:"transport_tls_trusted_ca_file" json:"transport_tls_trusted_ca_file"`
LogTo string `form:"log_to" json:"log_to"`
LogLevel string `form:"log_level" json:"log_level"`
LogMaxDays string `form:"log_max_days" json:"log_max_days"`
LogDisablePrintColor string `form:"log_disable_print_color" json:"log_disable_print_color"`
}
// ClientTune frpc 可视化公共参数
type ClientTune struct {
User string `form:"user" json:"user"`
ServerAddr string `form:"server_addr" json:"server_addr"`
ServerPort string `form:"server_port" json:"server_port"`
LoginFailExit string `form:"login_fail_exit" json:"login_fail_exit"`
NatHoleStunServer string `form:"nat_hole_stun_server" json:"nat_hole_stun_server"`
DNSServer string `form:"dns_server" json:"dns_server"`
UDPPacketSize string `form:"udp_packet_size" json:"udp_packet_size"`
AuthMethod string `form:"auth_method" json:"auth_method"`
AuthToken string `form:"auth_token" json:"auth_token"`
TransportProtocol string `form:"transport_protocol" json:"transport_protocol"`
TransportPoolCount string `form:"transport_pool_count" json:"transport_pool_count"`
TransportTCPMux string `form:"transport_tcp_mux" json:"transport_tcp_mux"`
TransportTCPMuxKeepaliveInterval string `form:"transport_tcp_mux_keepalive_interval" json:"transport_tcp_mux_keepalive_interval"`
TransportDialServerTimeout string `form:"transport_dial_server_timeout" json:"transport_dial_server_timeout"`
TransportDialServerKeepalive string `form:"transport_dial_server_keepalive" json:"transport_dial_server_keepalive"`
TransportHeartbeatInterval string `form:"transport_heartbeat_interval" json:"transport_heartbeat_interval"`
TransportHeartbeatTimeout string `form:"transport_heartbeat_timeout" json:"transport_heartbeat_timeout"`
TransportConnectServerLocalIP string `form:"transport_connect_server_local_ip" json:"transport_connect_server_local_ip"`
TransportProxyURL string `form:"transport_proxy_url" json:"transport_proxy_url"`
TransportTLSEnable string `form:"transport_tls_enable" json:"transport_tls_enable"`
TransportTLSCertFile string `form:"transport_tls_cert_file" json:"transport_tls_cert_file"`
TransportTLSKeyFile string `form:"transport_tls_key_file" json:"transport_tls_key_file"`
TransportTLSTrustedCaFile string `form:"transport_tls_trusted_ca_file" json:"transport_tls_trusted_ca_file"`
TransportTLSServerName string `form:"transport_tls_server_name" json:"transport_tls_server_name"`
WebServerAddr string `form:"web_server_addr" json:"web_server_addr"`
WebServerPort string `form:"web_server_port" json:"web_server_port"`
WebServerUser string `form:"web_server_user" json:"web_server_user"`
WebServerPassword string `form:"web_server_password" json:"web_server_password"`
WebServerPprofEnable string `form:"web_server_pprof_enable" json:"web_server_pprof_enable"`
LogTo string `form:"log_to" json:"log_to"`
LogLevel string `form:"log_level" json:"log_level"`
LogMaxDays string `form:"log_max_days" json:"log_max_days"`
LogDisablePrintColor string `form:"log_disable_print_color" json:"log_disable_print_color"`
}
+11 -24
View File
@@ -1,15 +1,14 @@
package gitea
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct{}
@@ -23,27 +22,15 @@ func (s *App) Route(r chi.Router) {
r.Post("/config", s.UpdateConfig)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("gitea")
return types.AggregateAppStatus(ok)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(fmt.Sprintf("%s/server/gitea/app.ini", app.Root))
service.Success(w, config)
common.ServeConfig(w, app.Root+"/server/gitea/app.ini")
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(fmt.Sprintf("%s/server/gitea/app.ini", app.Root), req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("gitea"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
common.SaveConfig(w, r, app.Root+"/server/gitea/app.ini", "gitea")
}
-5
View File
@@ -1,5 +0,0 @@
package gitea
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
+404
View File
@@ -0,0 +1,404 @@
package grafana
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"go.yaml.in/yaml/v4"
"resty.dev/v3"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
r.Get("/load", s.Load)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
// 数据源管理
r.Get("/datasources", s.DataSourceList)
r.Post("/datasources", s.CreateDataSource)
r.Post("/datasources/{name}", s.UpdateDataSource)
r.Delete("/datasources/{name}", s.DeleteDataSource)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("grafana")
return types.AggregateAppStatus(ok)
}
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, err := systemctl.Status("grafana")
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get grafana status: %v", err))
return
}
if !status {
service.Success(w, []types.NV{})
return
}
// 从 defaults.ini 获取端口
config, _ := io.Read(s.configPath())
port := confval.SectionINI.GetIn(config, "server", "http_port")
if port == "" {
port = "3000"
}
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.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
var health struct {
Commit string `json:"commit"`
Database string `json:"database"`
Version string `json:"version"`
}
if err = json.Unmarshal(resp.Bytes(), &health); err != nil {
service.Success(w, []types.NV{})
return
}
data := []types.NV{
{Name: s.t.Get("Version"), Value: health.Version},
{Name: "Commit", Value: health.Commit},
{Name: s.t.Get("Database"), Value: health.Database},
}
service.Success(w, data)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
common.ServeConfig(w, s.configPath())
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
common.SaveConfig(w, r, s.configPath(), "grafana")
}
// GetConfigTune 获取 Grafana 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(s.configPath())
get := func(section, key string) string {
return confval.SectionINI.GetIn(config, section, key)
}
tune := ConfigTune{
// [server]
HTTPPort: get("server", "http_port"),
Domain: get("server", "domain"),
RootURL: get("server", "root_url"),
Protocol: get("server", "protocol"),
// [database]
DBType: get("database", "type"),
DBHost: get("database", "host"),
DBName: get("database", "name"),
DBUser: get("database", "user"),
DBPassword: get("database", "password"),
// [security]
AdminUser: get("security", "admin_user"),
AdminPassword: get("security", "admin_password"),
// [users]
AllowSignUp: get("users", "allow_sign_up"),
AutoAssignOrgRole: get("users", "auto_assign_org_role"),
// [smtp]
SMTPEnabled: get("smtp", "enabled"),
SMTPHost: get("smtp", "host"),
SMTPUser: get("smtp", "user"),
SMTPPassword: get("smtp", "password"),
SMTPFromAddress: get("smtp", "from_address"),
// [log]
LogMode: get("log", "mode"),
LogLevel: get("log", "level"),
}
service.Success(w, tune)
}
// UpdateConfigTune 更新 Grafana 配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
config, _ := io.Read(s.configPath())
// [server]
config = confval.SectionINI.SetIn(config, "server", "http_port", req.HTTPPort)
config = confval.SectionINI.SetIn(config, "server", "domain", req.Domain)
config = confval.SectionINI.SetIn(config, "server", "root_url", req.RootURL)
config = confval.SectionINI.SetIn(config, "server", "protocol", req.Protocol)
// [database]
config = confval.SectionINI.SetIn(config, "database", "type", req.DBType)
config = confval.SectionINI.SetIn(config, "database", "host", req.DBHost)
config = confval.SectionINI.SetIn(config, "database", "name", req.DBName)
config = confval.SectionINI.SetIn(config, "database", "user", req.DBUser)
config = confval.SectionINI.SetIn(config, "database", "password", req.DBPassword)
// [security]
config = confval.SectionINI.SetIn(config, "security", "admin_user", req.AdminUser)
config = confval.SectionINI.SetIn(config, "security", "admin_password", req.AdminPassword)
// [users]
config = confval.SectionINI.SetIn(config, "users", "allow_sign_up", req.AllowSignUp)
config = confval.SectionINI.SetIn(config, "users", "auto_assign_org_role", req.AutoAssignOrgRole)
// [smtp]
config = confval.SectionINI.SetIn(config, "smtp", "enabled", req.SMTPEnabled)
config = confval.SectionINI.SetIn(config, "smtp", "host", req.SMTPHost)
config = confval.SectionINI.SetIn(config, "smtp", "user", req.SMTPUser)
config = confval.SectionINI.SetIn(config, "smtp", "password", req.SMTPPassword)
config = confval.SectionINI.SetIn(config, "smtp", "from_address", req.SMTPFromAddress)
// [log]
config = confval.SectionINI.SetIn(config, "log", "mode", req.LogMode)
config = confval.SectionINI.SetIn(config, "log", "level", req.LogLevel)
if err = io.Write(s.configPath(), config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("grafana"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// DataSourceList 获取数据源列表
func (s *App) DataSourceList(w http.ResponseWriter, r *http.Request) {
service.Success(w, s.getDatasourceList(s.readDatasources()))
}
// CreateDataSource 创建数据源
func (s *App) CreateDataSource(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[DataSource](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
cfg := s.readDatasources()
if lo.ContainsBy(s.getDatasourceList(cfg), func(item any) bool {
ds, ok := item.(map[string]any)
return ok && ds["name"] == req.Name
}) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("datasource %s already exists", req.Name))
return
}
if req.IsDefault {
s.clearDefault(cfg)
}
list := s.getDatasourceList(cfg)
list = append(list, s.buildDatasourceMap(req))
cfg["datasources"] = list
if err = s.writeDatasources(cfg); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// UpdateDataSource 更新数据源
func (s *App) UpdateDataSource(w http.ResponseWriter, r *http.Request) {
oldName := chi.URLParam(r, "name")
req, err := service.Bind[DataSource](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
cfg := s.readDatasources()
list := s.getDatasourceList(cfg)
_, idx, found := lo.FindIndexOf(list, func(item any) bool {
ds, ok := item.(map[string]any)
return ok && ds["name"] == oldName
})
if !found {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("datasource %s not found", oldName))
return
}
ds, _ := list[idx].(map[string]any)
newDs := s.buildDatasourceMap(req)
// 密码为空时保留原有 secureJsonData
if req.Password == "" {
if sec, exists := ds["secureJsonData"]; exists {
newDs["secureJsonData"] = sec
}
}
list[idx] = newDs
if req.IsDefault {
s.clearDefault(cfg)
lo.ForEach(list, func(item any, _ int) {
if ds, ok := item.(map[string]any); ok && ds["name"] == req.Name {
ds["isDefault"] = true
}
})
}
if oldName != req.Name {
s.addDeleteEntry(cfg, oldName)
}
cfg["datasources"] = list
if err = s.writeDatasources(cfg); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// DeleteDataSource 删除数据源
func (s *App) DeleteDataSource(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
cfg := s.readDatasources()
list := s.getDatasourceList(cfg)
newList := lo.Filter(list, func(item any, _ int) bool {
ds, ok := item.(map[string]any)
return !ok || ds["name"] != name
})
found := len(newList) < len(list)
if !found {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("datasource %s not found", name))
return
}
cfg["datasources"] = newList
s.addDeleteEntry(cfg, name)
if err := s.writeDatasources(cfg); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// configPath 返回 Grafana 主配置文件路径
func (s *App) configPath() string {
return app.Root + "/server/grafana/conf/defaults.ini"
}
// datasourcePath 返回 provisioning 数据源文件路径
func (s *App) datasourcePath() string {
return app.Root + "/server/grafana/conf/provisioning/datasources/panel.yml"
}
// readDatasources 读取 provisioning 文件
func (s *App) readDatasources() map[string]any {
raw, _ := io.Read(s.datasourcePath())
if raw == "" {
return map[string]any{
"apiVersion": 1,
"datasources": []any{},
}
}
var cfg map[string]any
if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil {
return map[string]any{
"apiVersion": 1,
"datasources": []any{},
}
}
return cfg
}
// writeDatasources 写入 provisioning 文件并重启 Grafana
func (s *App) writeDatasources(cfg map[string]any) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
if err = io.Write(s.datasourcePath(), string(data), 0644); err != nil {
return err
}
return systemctl.Restart("grafana")
}
// getDatasourceList 从 cfg 中提取 datasources 切片
func (s *App) getDatasourceList(cfg map[string]any) []any {
ds, ok := cfg["datasources"].([]any)
if !ok {
return []any{}
}
return ds
}
// buildDatasourceMap 从请求构建单条 datasource map
func (s *App) buildDatasourceMap(req *DataSource) map[string]any {
ds := map[string]any{
"name": req.Name,
"type": req.Type,
"access": req.Access,
"url": req.URL,
"isDefault": req.IsDefault,
"editable": true,
}
if req.Access == "" {
ds["access"] = "proxy"
}
switch req.Type {
case "mysql", "postgres", "influxdb", "mssql":
if req.Database != "" {
ds["database"] = req.Database
}
if req.User != "" {
ds["user"] = req.User
}
if req.Password != "" {
ds["secureJsonData"] = map[string]any{"password": req.Password}
}
}
return ds
}
// clearDefault 清除所有数据源的默认标记
func (s *App) clearDefault(cfg map[string]any) {
lo.ForEach(s.getDatasourceList(cfg), func(item any, _ int) {
if ds, ok := item.(map[string]any); ok {
ds["isDefault"] = false
}
})
}
// addDeleteEntry 向 deleteDatasources 添加条目
func (s *App) addDeleteEntry(cfg map[string]any, name string) {
delList, _ := cfg["deleteDatasources"].([]any)
delList = append(delList, map[string]any{"name": name, "orgId": 1})
cfg["deleteDatasources"] = delList
}
+44
View File
@@ -0,0 +1,44 @@
package grafana
// ConfigTune Grafana 配置调整
type ConfigTune struct {
// [server]
HTTPPort string `form:"http_port" json:"http_port"`
Domain string `form:"domain" json:"domain"`
RootURL string `form:"root_url" json:"root_url"`
Protocol string `form:"protocol" json:"protocol"`
// [database]
DBType string `form:"db_type" json:"db_type"`
DBHost string `form:"db_host" json:"db_host"`
DBName string `form:"db_name" json:"db_name"`
DBUser string `form:"db_user" json:"db_user"`
DBPassword string `form:"db_password" json:"db_password"`
// [security]
AdminUser string `form:"admin_user" json:"admin_user"`
AdminPassword string `form:"admin_password" json:"admin_password"`
// [users]
AllowSignUp string `form:"allow_sign_up" json:"allow_sign_up"`
AutoAssignOrgRole string `form:"auto_assign_org_role" json:"auto_assign_org_role"`
// [smtp]
SMTPEnabled string `form:"smtp_enabled" json:"smtp_enabled"`
SMTPHost string `form:"smtp_host" json:"smtp_host"`
SMTPUser string `form:"smtp_user" json:"smtp_user"`
SMTPPassword string `form:"smtp_password" json:"smtp_password"`
SMTPFromAddress string `form:"smtp_from_address" json:"smtp_from_address"`
// [log]
LogMode string `form:"log_mode" json:"log_mode"`
LogLevel string `form:"log_level" json:"log_level"`
}
// 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"`
URL string `form:"url" json:"url" validate:"required"`
Access string `form:"access" json:"access" validate:"in:proxy,direct"`
IsDefault bool `form:"is_default" json:"is_default"`
// 数据库类型专用
Database string `form:"database" json:"database"`
User string `form:"user" json:"user"`
Password string `form:"password" json:"password"`
}
+179
View File
@@ -0,0 +1,179 @@
package kafka
import (
"fmt"
"net/http"
"regexp"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
r.Get("/load", s.Load)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("kafka")
return types.AggregateAppStatus(ok)
}
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, err := systemctl.Status("kafka")
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get kafka status: %v", err))
return
}
if !status {
service.Success(w, []types.NV{})
return
}
config, _ := io.Read(s.configPath())
data := []types.NV{
{Name: s.t.Get("Node ID"), Value: confval.Properties.Get(config, "node.id")},
{Name: s.t.Get("Listeners"), Value: confval.Properties.Get(config, "listeners")},
{Name: s.t.Get("Log Dirs"), Value: confval.Properties.Get(config, "log.dirs")},
{Name: s.t.Get("Num Partitions"), Value: confval.Properties.Get(config, "num.partitions")},
{Name: s.t.Get("Log Retention Hours"), Value: confval.Properties.Get(config, "log.retention.hours")},
{Name: s.t.Get("Log Segment Bytes"), Value: confval.Properties.Get(config, "log.segment.bytes")},
}
service.Success(w, data)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
common.ServeConfig(w, s.configPath())
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
common.SaveConfig(w, r, s.configPath(), "kafka")
}
// GetConfigTune 获取 Kafka 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(s.configPath())
heapRaw, _ := io.Read(s.heapEnvPath())
heapInit, heapMax := s.parseHeapEnv(heapRaw)
tune := ConfigTune{
NodeID: confval.Properties.Get(config, "node.id"),
Listeners: confval.Properties.Get(config, "listeners"),
LogDirs: confval.Properties.Get(config, "log.dirs"),
NumPartitions: confval.Properties.Get(config, "num.partitions"),
RetentionHours: confval.Properties.Get(config, "log.retention.hours"),
LogSegmentBytes: confval.Properties.Get(config, "log.segment.bytes"),
HeapInitSize: heapInit,
HeapMaxSize: heapMax,
}
service.Success(w, tune)
}
// UpdateConfigTune 更新 Kafka 配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
config, _ := io.Read(s.configPath())
config = confval.Properties.Set(config, "node.id", req.NodeID)
config = confval.Properties.Set(config, "listeners", req.Listeners)
config = confval.Properties.Set(config, "log.dirs", req.LogDirs)
config = confval.Properties.Set(config, "num.partitions", req.NumPartitions)
config = confval.Properties.Set(config, "log.retention.hours", req.RetentionHours)
config = confval.Properties.Set(config, "log.segment.bytes", req.LogSegmentBytes)
if err = io.Write(s.configPath(), config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 更新 JVM 堆内存
if req.HeapInitSize != "" || req.HeapMaxSize != "" {
heapRaw, _ := io.Read(s.heapEnvPath())
heapRaw = s.setHeapEnv(heapRaw, req.HeapInitSize, req.HeapMaxSize)
if err = io.Write(s.heapEnvPath(), heapRaw, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
if err = systemctl.Restart("kafka"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// configPath 返回配置文件路径
func (s *App) configPath() string {
return app.Root + "/server/kafka/config/server.properties"
}
// heapEnvPath 返回 JVM 堆内存配置文件路径
func (s *App) heapEnvPath() string {
return app.Root + "/server/kafka/config/heap.env"
}
// parseHeapEnv 从 heap.env 中提取堆内存配置
func (s *App) parseHeapEnv(content string) (initSize, maxSize string) {
re := regexp.MustCompile(`KAFKA_HEAP_OPTS=(.+)`)
m := re.FindStringSubmatch(content)
if len(m) != 2 {
return
}
opts := m[1]
if mi := regexp.MustCompile(`-Xms(\S+)`).FindStringSubmatch(opts); len(mi) == 2 {
initSize = mi[1]
}
if mx := regexp.MustCompile(`-Xmx(\S+)`).FindStringSubmatch(opts); len(mx) == 2 {
maxSize = mx[1]
}
return
}
// setHeapEnv 设置 heap.env 中的堆内存配置
func (s *App) setHeapEnv(content string, initSize, maxSize string) string {
// 读取已有值作为默认
oldInit, oldMax := s.parseHeapEnv(content)
if initSize == "" {
initSize = oldInit
}
if maxSize == "" {
maxSize = oldMax
}
if initSize == "" {
initSize = "1g"
}
if maxSize == "" {
maxSize = "1g"
}
return fmt.Sprintf("KAFKA_HEAP_OPTS=-Xms%s -Xmx%s\n", initSize, maxSize)
}
+15
View File
@@ -0,0 +1,15 @@
package kafka
// ConfigTune Kafka 配置调整
type ConfigTune struct {
// Broker
NodeID string `form:"node_id" json:"node_id"` // node.id
Listeners string `form:"listeners" json:"listeners"` // listeners
LogDirs string `form:"log_dirs" json:"log_dirs"` // log.dirs
NumPartitions string `form:"num_partitions" json:"num_partitions"` // num.partitions
RetentionHours string `form:"retention_hours" json:"retention_hours"` // log.retention.hours
LogSegmentBytes string `form:"log_segment_bytes" json:"log_segment_bytes"` // log.segment.bytes
// JVM
HeapInitSize string `form:"heap_init_size" json:"heap_init_size"` // -Xms
HeapMaxSize string `form:"heap_max_size" json:"heap_max_size"` // -Xmx
}
+7 -5
View File
@@ -2,22 +2,24 @@ package mariadb
import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/internal/apps/mysql"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/v3/internal/apps/mysql"
)
type App struct {
mysql *mysql.App
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(mysqlApp *mysql.App) *App {
return &App{
mysql: mysql.NewApp(t, setting, databaseServer),
mysql: mysqlApp,
}
}
func (s *App) Route(r chi.Router) {
s.mysql.Route(r)
}
func (s *App) Status() string {
return s.mysql.Status()
}
+14 -30
View File
@@ -10,10 +10,11 @@ import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/pkg/types"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
@@ -34,6 +35,11 @@ func (s *App) Route(r chi.Router) {
r.Post("/config_tune", s.UpdateConfigTune)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("memcached")
return types.AggregateAppStatus(ok)
}
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, err := systemctl.Status("memcached")
if err != nil {
@@ -85,33 +91,11 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read("/etc/systemd/system/memcached.service")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, config)
common.ServeConfig(w, "/etc/systemd/system/memcached.service")
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write("/etc/systemd/system/memcached.service", req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("memcached"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
common.SaveConfig(w, r, "/etc/systemd/system/memcached.service", "memcached")
}
// GetConfigTune 获取 Memcached 配置调整参数
@@ -170,8 +154,8 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
// getExecStartArg 从 systemd service 文件的 ExecStart 行中获取指定参数值
func (s *App) getExecStartArg(content string, flag string) string {
lines := strings.Split(content, "\n")
for _, line := range lines {
lines := strings.SplitSeq(content, "\n")
for line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "ExecStart=") {
continue
+5 -9
View File
@@ -1,15 +1,11 @@
package memcached
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// 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"`
}
+9 -3
View File
@@ -5,9 +5,10 @@ import (
"github.com/go-chi/chi/v5"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct{}
@@ -21,6 +22,11 @@ func (s *App) Route(r chi.Router) {
r.Post("/env", s.UpdateEnv)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("minio")
return types.AggregateAppStatus(ok)
}
func (s *App) GetEnv(w http.ResponseWriter, r *http.Request) {
env, _ := io.Read("/etc/default/minio")
service.Success(w, env)
+262
View File
@@ -0,0 +1,262 @@
package mongodb
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/spf13/cast"
"go.yaml.in/yaml/v4"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/service"
"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
settingRepo biz.SettingRepo
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) *App {
return &App{
t: t,
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
}
}
func (s *App) Route(r chi.Router) {
r.Get("/load", s.Load)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
r.Get("/admin_password", s.GetAdminPassword)
r.Post("/admin_password", s.SetAdminPassword)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("mongod")
return types.AggregateAppStatus(ok)
}
// Load 获取 MongoDB 运行状态
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, _ := systemctl.Status("mongod")
if !status {
service.Success(w, []types.NV{})
return
}
password, _ := s.settingRepo.Get(biz.SettingKeyMongoDBAdminPassword)
raw, err := shell.Execf(`mongosh --quiet --eval "JSON.stringify(db.serverStatus())" -u admin -p '%s' --authenticationDatabase admin 2>/dev/null`, password)
if err != nil {
service.Success(w, []types.NV{})
return
}
var status2 struct {
Uptime int `json:"uptime"`
Connections struct {
Current int `json:"current"`
TotalCreated int `json:"totalCreated"`
} `json:"connections"`
OpCounters struct {
Query int `json:"query"`
Insert int `json:"insert"`
Update int `json:"update"`
Delete int `json:"delete"`
} `json:"opcounters"`
Mem struct {
Resident int `json:"resident"`
} `json:"mem"`
StorageEngine struct {
Name string `json:"name"`
} `json:"storageEngine"`
Version string `json:"version"`
}
if err = json.Unmarshal([]byte(raw), &status2); err != nil {
service.Success(w, []types.NV{})
return
}
data := []types.NV{
{Name: s.t.Get("Version"), Value: status2.Version},
{Name: s.t.Get("Uptime (seconds)"), Value: cast.ToString(status2.Uptime)},
{Name: s.t.Get("Current Connections"), Value: cast.ToString(status2.Connections.Current)},
{Name: s.t.Get("Total Connections Created"), Value: cast.ToString(status2.Connections.TotalCreated)},
{Name: s.t.Get("Query Operations"), Value: cast.ToString(status2.OpCounters.Query)},
{Name: s.t.Get("Insert Operations"), Value: cast.ToString(status2.OpCounters.Insert)},
{Name: s.t.Get("Update Operations"), Value: cast.ToString(status2.OpCounters.Update)},
{Name: s.t.Get("Delete Operations"), Value: cast.ToString(status2.OpCounters.Delete)},
{Name: s.t.Get("Resident Memory (MB)"), Value: cast.ToString(status2.Mem.Resident)},
{Name: s.t.Get("Storage Engine"), Value: status2.StorageEngine.Name},
}
service.Success(w, data)
}
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
common.ServeConfig(w, s.configPath())
}
// UpdateConfig 更新配置
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
common.SaveConfig(w, r, s.configPath(), "mongod")
}
// GetConfigTune 获取配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
raw, _ := io.Read(s.configPath())
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg == nil {
cfg = make(map[string]any)
}
tune := ConfigTune{
DbPath: s.getYAMLValue(cfg, "storage.dbPath"),
CacheSizeGB: s.getYAMLValue(cfg, "storage.wiredTiger.engineConfig.cacheSizeGB"),
Port: s.getYAMLValue(cfg, "net.port"),
BindIp: s.getYAMLValue(cfg, "net.bindIp"),
SystemLogPath: s.getYAMLValue(cfg, "systemLog.path"),
Authorization: s.getYAMLValue(cfg, "security.authorization"),
}
service.Success(w, tune)
}
// UpdateConfigTune 更新配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
raw, _ := io.Read(s.configPath())
var cfg map[string]any
if err = yaml.Unmarshal([]byte(raw), &cfg); err != nil {
cfg = make(map[string]any)
}
s.setNestedYAMLValue(cfg, "storage.dbPath", req.DbPath)
s.setNestedYAMLValue(cfg, "storage.wiredTiger.engineConfig.cacheSizeGB", req.CacheSizeGB)
s.setNestedYAMLValue(cfg, "net.port", req.Port)
s.setNestedYAMLValue(cfg, "net.bindIp", req.BindIp)
s.setNestedYAMLValue(cfg, "systemLog.path", req.SystemLogPath)
s.setNestedYAMLValue(cfg, "security.authorization", req.Authorization)
data, err := yaml.Marshal(cfg)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(s.configPath(), string(data), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("mongod"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// GetAdminPassword 获取 admin 密码
func (s *App) GetAdminPassword(w http.ResponseWriter, r *http.Request) {
password, err := s.settingRepo.Get(biz.SettingKeyMongoDBAdminPassword)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get MongoDB admin password: %v", err))
return
}
service.Success(w, password)
}
// SetAdminPassword 设置 admin 密码
func (s *App) SetAdminPassword(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[SetAdminPassword](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
oldPassword, _ := s.settingRepo.Get(biz.SettingKeyMongoDBAdminPassword)
// 尝试用旧密码连接修改
_, err = shell.Execf(`mongosh --quiet -u admin -p '%s' --authenticationDatabase admin --eval "db.changeUserPassword('admin', '%s')"`, oldPassword, req.Password)
if err != nil {
// 回退:停止服务,无认证模式修改
_ = systemctl.Stop("mongod")
_, _ = shell.Execf(`su -s /bin/bash mongod -c "mongod --config %s --noauth --fork --logpath /tmp/mongod_reset.log"`, s.configPath())
_, resetErr := shell.Execf(`mongosh --quiet --eval "db.getSiblingDB('admin').changeUserPassword('admin', '%s')"`, req.Password)
_, _ = shell.Execf(`su -s /bin/bash mongod -c "mongod --config %s --shutdown" 2>/dev/null; pkill -f 'mongod --config.*--noauth' 2>/dev/null`, s.configPath())
_ = systemctl.Start("mongod")
if resetErr != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to set MongoDB admin password: %v", resetErr))
return
}
}
if err = s.settingRepo.Set(biz.SettingKeyMongoDBAdminPassword, req.Password); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to save MongoDB admin password: %v", err))
return
}
_ = s.databaseServerRepo.UpdatePassword("local_mongodb", req.Password)
service.Success(w, nil)
}
// configPath 返回配置文件路径
func (s *App) configPath() string {
return app.Root + "/server/mongodb/mongod.conf"
}
// getYAMLValue 获取嵌套 YAML 值,支持 dot notation
func (s *App) getYAMLValue(cfg map[string]any, key string) string {
parts := strings.SplitN(key, ".", 2)
val, ok := cfg[parts[0]]
if !ok {
return ""
}
if len(parts) == 1 {
return cast.ToString(val)
}
nested, ok := val.(map[string]any)
if !ok {
return ""
}
return s.getYAMLValue(nested, parts[1])
}
// setNestedYAMLValue 设置嵌套 YAML 值,逐层创建 map
func (s *App) setNestedYAMLValue(cfg map[string]any, key string, value string) {
if value == "" {
return
}
parts := strings.SplitN(key, ".", 2)
if len(parts) == 1 {
cfg[parts[0]] = value
return
}
nested, ok := cfg[parts[0]].(map[string]any)
if !ok {
nested = make(map[string]any)
cfg[parts[0]] = nested
}
s.setNestedYAMLValue(nested, parts[1], value)
}
+20
View File
@@ -0,0 +1,20 @@
package mongodb
// SetAdminPassword 设置 admin 密码
type SetAdminPassword struct {
Password string `form:"password" json:"password" validate:"required && password"`
}
// ConfigTune MongoDB 配置调整
type ConfigTune struct {
// 存储
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" validate:"number && min:1 && max:65535"`
BindIp string `form:"bind_ip" json:"bind_ip"`
// 日志
SystemLogPath string `form:"system_log_path" json:"system_log_path" validate:"unix_path"`
// 安全
Authorization string `form:"authorization" json:"authorization" validate:"in:enabled,disabled"`
}
+644 -212
View File
@@ -1,38 +1,43 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
"regexp"
"slices"
"strings"
"github.com/acepanel/panel/pkg/types"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/spf13/cast"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/db"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/pkg/tools"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/db"
"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/tools"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
settingRepo biz.SettingRepo
databaseServerRepo biz.DatabaseServerRepo
taskRepo biz.TaskRepo
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo, taskRepo biz.TaskRepo) *App {
return &App{
t: t,
settingRepo: setting,
databaseServerRepo: databaseServer,
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
taskRepo: taskRepo,
}
}
@@ -40,45 +45,30 @@ func (s *App) Route(r chi.Router) {
r.Get("/load", s.Load)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Post("/clear_log", s.ClearLog)
r.Get("/slow_log", s.SlowLog)
r.Post("/clear_slow_log", s.ClearSlowLog)
r.Get("/root_password", s.GetRootPassword)
r.Post("/root_password", s.SetRootPassword)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
// 性能
r.Get("/processes", s.ProcessList)
r.Post("/processes/{id}/kill", s.KillProcess)
r.Get("/transactions", s.TransactionList)
r.Get("/top_sql", s.TopSQL)
r.Post("/top_sql/enable", s.EnableTopSQL)
r.Post("/top_sql/reset", s.ResetTopSQL)
// 维护
r.Get("/databases", s.DatabaseList)
r.Get("/tables", s.TableList)
r.Post("/maintenance", s.RunMaintenance)
r.Get("/binlogs", s.BinlogList)
r.Post("/binlogs/purge", s.PurgeBinlog)
r.Get("/replication", s.ReplicationStatus)
}
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(app.Root + "/server/mysql/conf/my.cnf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, config)
}
// UpdateConfig 保存配置
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(app.Root+"/server/mysql/conf/my.cnf", req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("mysqld"); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to restart MySQL: %v", err))
return
}
service.Success(w, nil)
func (s *App) Status() string {
ok, _ := systemctl.Status("mysqld")
return types.AggregateAppStatus(ok)
}
// Load 获取负载
@@ -95,19 +85,11 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
return
}
if err = os.Setenv("MYSQL_PWD", rootPassword); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to set MYSQL_PWD env: %v", err))
return
}
raw, err := shell.Execf(`mysqladmin -u root extended-status`)
raw, err := shell.ExecfWithEnv([]string{"MYSQL_PWD=" + rootPassword}, `mysqladmin -u root extended-status`)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get MySQL status: %v", err))
return
}
if err = os.Unsetenv("MYSQL_PWD"); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to unset MYSQL_PWD env: %v", err))
return
}
var load []map[string]string
expressions := []struct {
@@ -194,29 +176,41 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
service.Success(w, load)
}
// ClearLog 清空日志
func (s *App) ClearLog(w http.ResponseWriter, r *http.Request) {
if err := systemctl.LogClear("mysqld"); err != nil {
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(app.Root + "/server/mysql/conf/my.cnf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, config)
}
// UpdateConfig 保存配置
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(app.Root+"/server/mysql/conf/my.cnf", req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("mysqld"); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to restart MySQL: %v", err))
return
}
service.Success(w, nil)
}
// SlowLog 获取慢查询日志
func (s *App) SlowLog(w http.ResponseWriter, r *http.Request) {
service.Success(w, fmt.Sprintf("%s/server/mysql/mysql-slow.log", app.Root))
}
// ClearSlowLog 清空慢查询日志
func (s *App) ClearSlowLog(w http.ResponseWriter, r *http.Request) {
if _, err := shell.Execf("cat /dev/null > %s/server/mysql/mysql-slow.log", app.Root); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
service.Success(w, app.Root+"/server/mysql/mysql-slow.log")
}
// GetRootPassword 获取root密码
@@ -239,10 +233,10 @@ 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, db.MySQLSocket(app.Root), "unix")
if err != nil {
// 尝试安全模式直接改密
if err = db.MySQLResetRootPassword(req.Password); err != nil {
if err = db.MySQLResetRootPassword(req.Password, app.Root); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -264,30 +258,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")
@@ -298,35 +268,35 @@ func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
tune := ConfigTune{
// 常规设置
Port: s.getINIValue(config, "port"),
MaxConnections: s.getINIValue(config, "max_connections"),
MaxConnectErrors: s.getINIValue(config, "max_connect_errors"),
DefaultStorageEngine: s.getINIValue(config, "default_storage_engine"),
TableOpenCache: s.getINIValue(config, "table_open_cache"),
MaxAllowedPacket: s.getINIValue(config, "max_allowed_packet"),
OpenFilesLimit: s.getINIValue(config, "open_files_limit"),
Port: confval.INI.Get(config, "port"),
MaxConnections: confval.INI.Get(config, "max_connections"),
MaxConnectErrors: confval.INI.Get(config, "max_connect_errors"),
DefaultStorageEngine: confval.INI.Get(config, "default_storage_engine"),
TableOpenCache: confval.INI.Get(config, "table_open_cache"),
MaxAllowedPacket: confval.INI.Get(config, "max_allowed_packet"),
OpenFilesLimit: confval.INI.Get(config, "open_files_limit"),
// 性能调整
KeyBufferSize: s.getINIValue(config, "key_buffer_size"),
SortBufferSize: s.getINIValue(config, "sort_buffer_size"),
ReadBufferSize: s.getINIValue(config, "read_buffer_size"),
ReadRndBufferSize: s.getINIValue(config, "read_rnd_buffer_size"),
JoinBufferSize: s.getINIValue(config, "join_buffer_size"),
ThreadCacheSize: s.getINIValue(config, "thread_cache_size"),
ThreadStack: s.getINIValue(config, "thread_stack"),
TmpTableSize: s.getINIValue(config, "tmp_table_size"),
MaxHeapTableSize: s.getINIValue(config, "max_heap_table_size"),
MyisamSortBufferSize: s.getINIValue(config, "myisam_sort_buffer_size"),
KeyBufferSize: confval.INI.Get(config, "key_buffer_size"),
SortBufferSize: confval.INI.Get(config, "sort_buffer_size"),
ReadBufferSize: confval.INI.Get(config, "read_buffer_size"),
ReadRndBufferSize: confval.INI.Get(config, "read_rnd_buffer_size"),
JoinBufferSize: confval.INI.Get(config, "join_buffer_size"),
ThreadCacheSize: confval.INI.Get(config, "thread_cache_size"),
ThreadStack: confval.INI.Get(config, "thread_stack"),
TmpTableSize: confval.INI.Get(config, "tmp_table_size"),
MaxHeapTableSize: confval.INI.Get(config, "max_heap_table_size"),
MyisamSortBufferSize: confval.INI.Get(config, "myisam_sort_buffer_size"),
// InnoDB
InnodbBufferPoolSize: s.getINIValue(config, "innodb_buffer_pool_size"),
InnodbLogBufferSize: s.getINIValue(config, "innodb_log_buffer_size"),
InnodbFlushLogAtTrxCommit: s.getINIValue(config, "innodb_flush_log_at_trx_commit"),
InnodbLockWaitTimeout: s.getINIValue(config, "innodb_lock_wait_timeout"),
InnodbMaxDirtyPagesPct: s.getINIValue(config, "innodb_max_dirty_pages_pct"),
InnodbReadIoThreads: s.getINIValue(config, "innodb_read_io_threads"),
InnodbWriteIoThreads: s.getINIValue(config, "innodb_write_io_threads"),
InnodbBufferPoolSize: confval.INI.Get(config, "innodb_buffer_pool_size"),
InnodbLogBufferSize: confval.INI.Get(config, "innodb_log_buffer_size"),
InnodbFlushLogAtTrxCommit: confval.INI.Get(config, "innodb_flush_log_at_trx_commit"),
InnodbLockWaitTimeout: confval.INI.Get(config, "innodb_lock_wait_timeout"),
InnodbMaxDirtyPagesPct: confval.INI.Get(config, "innodb_max_dirty_pages_pct"),
InnodbReadIoThreads: confval.INI.Get(config, "innodb_read_io_threads"),
InnodbWriteIoThreads: confval.INI.Get(config, "innodb_write_io_threads"),
// 日志
SlowQueryLog: s.getINIValue(config, "slow_query_log"),
LongQueryTime: s.getINIValue(config, "long_query_time"),
SlowQueryLog: confval.INI.Get(config, "slow_query_log"),
LongQueryTime: confval.INI.Get(config, "long_query_time"),
}
service.Success(w, tune)
@@ -348,35 +318,35 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
}
// 更新常规设置
config = s.setINIValue(config, "port", req.Port)
config = s.setINIValue(config, "max_connections", req.MaxConnections)
config = s.setINIValue(config, "max_connect_errors", req.MaxConnectErrors)
config = s.setINIValue(config, "default_storage_engine", req.DefaultStorageEngine)
config = s.setINIValue(config, "table_open_cache", req.TableOpenCache)
config = s.setINIValue(config, "max_allowed_packet", req.MaxAllowedPacket)
config = s.setINIValue(config, "open_files_limit", req.OpenFilesLimit)
config = confval.INI.Set(config, "port", req.Port)
config = confval.INI.Set(config, "max_connections", req.MaxConnections)
config = confval.INI.Set(config, "max_connect_errors", req.MaxConnectErrors)
config = confval.INI.Set(config, "default_storage_engine", req.DefaultStorageEngine)
config = confval.INI.Set(config, "table_open_cache", req.TableOpenCache)
config = confval.INI.Set(config, "max_allowed_packet", req.MaxAllowedPacket)
config = confval.INI.Set(config, "open_files_limit", req.OpenFilesLimit)
// 更新性能调整
config = s.setINIValue(config, "key_buffer_size", req.KeyBufferSize)
config = s.setINIValue(config, "sort_buffer_size", req.SortBufferSize)
config = s.setINIValue(config, "read_buffer_size", req.ReadBufferSize)
config = s.setINIValue(config, "read_rnd_buffer_size", req.ReadRndBufferSize)
config = s.setINIValue(config, "join_buffer_size", req.JoinBufferSize)
config = s.setINIValue(config, "thread_cache_size", req.ThreadCacheSize)
config = s.setINIValue(config, "thread_stack", req.ThreadStack)
config = s.setINIValue(config, "tmp_table_size", req.TmpTableSize)
config = s.setINIValue(config, "max_heap_table_size", req.MaxHeapTableSize)
config = s.setINIValue(config, "myisam_sort_buffer_size", req.MyisamSortBufferSize)
config = confval.INI.Set(config, "key_buffer_size", req.KeyBufferSize)
config = confval.INI.Set(config, "sort_buffer_size", req.SortBufferSize)
config = confval.INI.Set(config, "read_buffer_size", req.ReadBufferSize)
config = confval.INI.Set(config, "read_rnd_buffer_size", req.ReadRndBufferSize)
config = confval.INI.Set(config, "join_buffer_size", req.JoinBufferSize)
config = confval.INI.Set(config, "thread_cache_size", req.ThreadCacheSize)
config = confval.INI.Set(config, "thread_stack", req.ThreadStack)
config = confval.INI.Set(config, "tmp_table_size", req.TmpTableSize)
config = confval.INI.Set(config, "max_heap_table_size", req.MaxHeapTableSize)
config = confval.INI.Set(config, "myisam_sort_buffer_size", req.MyisamSortBufferSize)
// 更新 InnoDB
config = s.setINIValue(config, "innodb_buffer_pool_size", req.InnodbBufferPoolSize)
config = s.setINIValue(config, "innodb_log_buffer_size", req.InnodbLogBufferSize)
config = s.setINIValue(config, "innodb_flush_log_at_trx_commit", req.InnodbFlushLogAtTrxCommit)
config = s.setINIValue(config, "innodb_lock_wait_timeout", req.InnodbLockWaitTimeout)
config = s.setINIValue(config, "innodb_max_dirty_pages_pct", req.InnodbMaxDirtyPagesPct)
config = s.setINIValue(config, "innodb_read_io_threads", req.InnodbReadIoThreads)
config = s.setINIValue(config, "innodb_write_io_threads", req.InnodbWriteIoThreads)
config = confval.INI.Set(config, "innodb_buffer_pool_size", req.InnodbBufferPoolSize)
config = confval.INI.Set(config, "innodb_log_buffer_size", req.InnodbLogBufferSize)
config = confval.INI.Set(config, "innodb_flush_log_at_trx_commit", req.InnodbFlushLogAtTrxCommit)
config = confval.INI.Set(config, "innodb_lock_wait_timeout", req.InnodbLockWaitTimeout)
config = confval.INI.Set(config, "innodb_max_dirty_pages_pct", req.InnodbMaxDirtyPagesPct)
config = confval.INI.Set(config, "innodb_read_io_threads", req.InnodbReadIoThreads)
config = confval.INI.Set(config, "innodb_write_io_threads", req.InnodbWriteIoThreads)
// 更新日志
config = s.setINIValue(config, "slow_query_log", req.SlowQueryLog)
config = s.setINIValue(config, "long_query_time", req.LongQueryTime)
config = confval.INI.Set(config, "slow_query_log", req.SlowQueryLog)
config = confval.INI.Set(config, "long_query_time", req.LongQueryTime)
if err = io.Write(confPath, config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -386,76 +356,538 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
// getINIValue 从 INI 格式内容中获取指定键的值
func (s *App) getINIValue(content string, key string) string {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "#") {
continue
}
if strings.HasPrefix(trimmed, "[") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
continue
}
k := strings.TrimSpace(parts[0])
if k == key {
return strings.TrimSpace(parts[1])
}
// ProcessList 获取进程列表
func (s *App) ProcessList(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
return ""
defer mysql.Close()
rows, err := mysql.Query(`
SELECT ID, coalesce(USER,''), coalesce(HOST,''), coalesce(DB,''), coalesce(COMMAND,''),
TIME, coalesce(STATE,''), coalesce(INFO,'')
FROM information_schema.PROCESSLIST WHERE ID != CONNECTION_ID() ORDER BY TIME DESC`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
processes := make([]Process, 0)
for rows.Next() {
var item Process
if err = rows.Scan(&item.ID, &item.User, &item.Host, &item.DB, &item.Command, &item.Time, &item.State, &item.Info); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
processes = append(processes, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, processes)
}
// setINIValue 在 INI 格式内容中设置指定键的值
func (s *App) setINIValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
// KillProcess 终止进程
func (s *App) KillProcess(w http.ResponseWriter, r *http.Request) {
id := cast.ToInt64(chi.URLParam(r, "id"))
if id <= 0 {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid process id"))
return
}
lines := strings.Split(content, "\n")
found := false
result := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "[") {
result = append(result, line)
continue
}
checkLine := trimmed
if strings.HasPrefix(checkLine, ";") {
checkLine = strings.TrimSpace(checkLine[1:])
} else if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
}
parts := strings.SplitN(checkLine, "=", 2)
if len(parts) != 2 {
result = append(result, line)
continue
}
k := strings.TrimSpace(parts[0])
if k == key {
if found {
continue
}
found = true
// 值为空时注释掉该配置项
if value == "" {
if !strings.HasPrefix(trimmed, ";") && !strings.HasPrefix(trimmed, "#") {
result = append(result, "#"+line)
} else {
result = append(result, line)
}
continue
}
result = append(result, key+" = "+value)
} else {
result = append(result, line)
}
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if !found && value != "" {
result = append(result, key+" = "+value)
defer mysql.Close()
// KILL 不支持预编译参数,id 已校验为正整数
if _, err = mysql.Exec(fmt.Sprintf(`KILL %d`, id)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
return strings.Join(result, "\n")
service.Success(w, nil)
}
// TransactionList 获取事务及锁等待列表
func (s *App) TransactionList(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
rows, err := mysql.Query(`
SELECT trx_id, trx_mysql_thread_id, coalesce(trx_state,''), coalesce(trx_query,''),
timestampdiff(SECOND, trx_started, now()), trx_rows_locked, trx_rows_modified
FROM information_schema.INNODB_TRX ORDER BY trx_started`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
result := Transactions{Transactions: make([]Transaction, 0), LockWaits: make([]LockWait, 0)}
for rows.Next() {
var item Transaction
if err = rows.Scan(&item.ID, &item.ThreadID, &item.State, &item.Query, &item.Seconds, &item.RowsLocked, &item.RowsModified); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
result.Transactions = append(result.Transactions, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 锁等待对,MariaDB 与 MySQL 8 的表不同,查询失败时忽略
lockSQL := `
SELECT r.trx_mysql_thread_id, coalesce(r.trx_query,''), b.trx_mysql_thread_id, coalesce(b.trx_query,'')
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx r ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID
JOIN information_schema.innodb_trx b ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID`
if s.isMariaDB(mysql) {
lockSQL = `
SELECT r.trx_mysql_thread_id, coalesce(r.trx_query,''), b.trx_mysql_thread_id, coalesce(b.trx_query,'')
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id`
}
if lockRows, lockErr := mysql.Query(lockSQL); lockErr == nil {
defer func() { _ = lockRows.Close() }()
for lockRows.Next() {
var item LockWait
if err = lockRows.Scan(&item.WaitingThreadID, &item.WaitingQuery, &item.BlockingThreadID, &item.BlockingQuery); err != nil {
break
}
result.LockWaits = append(result.LockWaits, item)
}
}
service.Success(w, result)
}
// TopSQL 获取 SQL 性能统计
func (s *App) TopSQL(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
var enabled int
if err = mysql.QueryRow(`SELECT @@performance_schema`).Scan(&enabled); err != nil {
if strings.Contains(err.Error(), "Unknown system variable") {
service.Success(w, TopSQL{Supported: false, Items: []TopSQLItem{}})
return
}
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if enabled == 0 {
// 检查是否已配置等待重启
config, _ := io.Read(app.Root + "/server/mysql/conf/my.cnf")
pending := strings.EqualFold(confval.SectionINI.GetIn(config, "mysqld", "performance_schema"), "on")
service.Success(w, TopSQL{Supported: true, Enabled: false, PendingRestart: pending, Items: []TopSQLItem{}})
return
}
rows, err := mysql.Query(`
SELECT coalesce(SCHEMA_NAME,''), COUNT_STAR, SUM_TIMER_WAIT DIV 1000000000,
round(AVG_TIMER_WAIT/1e9,2), SUM_ROWS_SENT, SUM_ROWS_EXAMINED, coalesce(DIGEST_TEXT,'')
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST_TEXT IS NOT NULL ORDER BY SUM_TIMER_WAIT DESC LIMIT 50`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
items := make([]TopSQLItem, 0)
for rows.Next() {
var item TopSQLItem
if err = rows.Scan(&item.Database, &item.Calls, &item.TotalMs, &item.MeanMs, &item.RowsSent, &item.RowsExamined, &item.Query); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, TopSQL{Supported: true, Enabled: true, Items: items})
}
// EnableTopSQL 启用 performance_schema
func (s *App) EnableTopSQL(w http.ResponseWriter, r *http.Request) {
// 实例不支持时禁止写入配置
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
var enabled int
if err = mysql.QueryRow(`SELECT @@performance_schema`).Scan(&enabled); err != nil {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("performance_schema is not supported by this instance"))
return
}
confPath := app.Root + "/server/mysql/conf/my.cnf"
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// my.cnf 分段,必须用 SectionINI 保证写入 [mysqld] 段内,重启后生效
config = confval.SectionINI.SetIn(config, "mysqld", "performance_schema", "on")
if err = io.Write(confPath, config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// ResetTopSQL 重置 SQL 性能统计
func (s *App) ResetTopSQL(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
if _, err = mysql.Exec(`TRUNCATE TABLE performance_schema.events_statements_summary_by_digest`); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// DatabaseList 获取业务数据库列表
func (s *App) DatabaseList(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
rows, err := mysql.Query(`
SELECT SCHEMA_NAME FROM information_schema.SCHEMATA
WHERE SCHEMA_NAME NOT IN ('mysql','information_schema','performance_schema','sys')
ORDER BY SCHEMA_NAME`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
databases := make([]string, 0)
for rows.Next() {
var name string
if err = rows.Scan(&name); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
databases = append(databases, name)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, databases)
}
// TableList 获取表维护信息
func (s *App) TableList(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[TableQuery](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
query := `
SELECT table_schema, table_name, coalesce(engine,''), coalesce(table_rows,0),
coalesce(data_length + index_length,0), coalesce(data_free,0)
FROM information_schema.TABLES
WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')
AND table_type = 'BASE TABLE'`
var args []any
if req.Database != "" {
query += ` AND table_schema = ?`
args = append(args, req.Database)
}
query += ` ORDER BY data_length + index_length DESC LIMIT 50`
rows, err := mysql.Query(query, args...)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
items := make([]TableInfo, 0)
for rows.Next() {
var item TableInfo
var size, free int64
if err = rows.Scan(&item.Database, &item.Table, &item.Engine, &item.Rows, &size, &free); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
item.Size = tools.FormatBytes(float64(size))
item.SizeBytes = size
if size+free > 0 {
item.FragmentRate = float64(free) * 100 / float64(size+free)
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, items)
}
// RunMaintenance 对表执行维护操作(异步任务)
func (s *App) RunMaintenance(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[MaintenanceRun](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if len(req.Tables) == 0 {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("no tables selected"))
return
}
if !slices.Contains([]string{"optimize", "analyze"}, req.Operation) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid operation"))
return
}
rootPassword, err := s.settingRepo.Get(biz.SettingKeyMySQLRootPassword)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
tables := make([]string, 0, len(req.Tables))
for _, table := range req.Tables {
tables = append(tables, fmt.Sprintf("`%s`.`%s`", table.Database, table.Table))
}
escaped := strings.ReplaceAll(rootPassword, `'`, `'\''`)
cmd := fmt.Sprintf("MYSQL_PWD='%s' mysql -u root -e '%s TABLE %s'", escaped, strings.ToUpper(req.Operation), strings.Join(tables, ", "))
task := new(biz.Task)
task.Key = "mysql:maintenance"
task.Name = s.t.Get("Run %[1]s on %[2]d tables", req.Operation, len(req.Tables))
task.Status = biz.TaskStatusWaiting
task.Shell = cmd
if err = s.taskRepo.Push(task); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// BinlogList 获取 binlog 状态
func (s *App) BinlogList(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
var enabled int
if err = mysql.QueryRow(`SELECT @@log_bin`).Scan(&enabled); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if enabled == 0 {
service.Success(w, Binlog{Enabled: false, Items: []BinlogFile{}})
return
}
rows, err := mysql.Query(`SHOW BINARY LOGS`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// SHOW BINARY LOGS 列数两派不同(MySQL 8 多 Encrypted 列),动态取列
maps, err := s.rowsToMaps(rows)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
binlog := Binlog{Enabled: true, Items: make([]BinlogFile, 0, len(maps))}
var total int64
for _, m := range maps {
size := cast.ToInt64(m["File_size"])
total += size
binlog.Items = append(binlog.Items, BinlogFile{Name: m["Log_name"], Size: tools.FormatBytes(float64(size)), SizeBytes: size})
}
binlog.TotalSize = tools.FormatBytes(float64(total))
service.Success(w, binlog)
}
// PurgeBinlog 清理指定文件之前的 binlog
func (s *App) PurgeBinlog(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[BinlogPurge](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
// 校验文件名存在于 binlog 列表,PURGE 不支持预编译参数
rows, err := mysql.Query(`SHOW BINARY LOGS`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
maps, err := s.rowsToMaps(rows)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if !slices.ContainsFunc(maps, func(m map[string]string) bool { return m["Log_name"] == req.File }) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("binlog file %s does not exist", req.File))
return
}
if _, err = mysql.Exec(fmt.Sprintf(`PURGE BINARY LOGS TO '%s'`, req.File)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// ReplicationStatus 获取复制状态
func (s *App) ReplicationStatus(w http.ResponseWriter, r *http.Request) {
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
rows, err := mysql.Query(`SHOW REPLICA STATUS`)
if err != nil {
// 老版本 fallback
if rows, err = mysql.Query(`SHOW SLAVE STATUS`); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
maps, err := s.rowsToMaps(rows)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if len(maps) == 0 {
service.Success(w, Replication{Enabled: false})
return
}
// MySQL 8 与 MariaDB 的列名两派不同,按候选名取值
pick := func(m map[string]string, keys ...string) string {
for _, key := range keys {
if v, ok := m[key]; ok && v != "" {
return v
}
}
return ""
}
m := maps[0]
service.Success(w, Replication{
Enabled: true,
IORunning: pick(m, "Replica_IO_Running", "Slave_IO_Running"),
SQLRunning: pick(m, "Replica_SQL_Running", "Slave_SQL_Running"),
SecondsBehind: pick(m, "Seconds_Behind_Source", "Seconds_Behind_Master"),
SourceHost: pick(m, "Source_Host", "Master_Host"),
LastError: pick(m, "Last_Error"),
})
}
// connect 以 root 用户通过 unix socket 连接
func (s *App) connect(ctx context.Context) (db.Operator, error) {
rootPassword, err := s.settingRepo.Get(biz.SettingKeyMySQLRootPassword)
if err != nil {
return nil, err
}
return db.NewMySQL(ctx, "root", rootPassword, db.MySQLSocket(app.Root), "unix")
}
// isMariaDB 判断当前实例是否为 MariaDB
func (s *App) isMariaDB(op db.Operator) bool {
var version string
if err := op.QueryRow(`SELECT VERSION()`).Scan(&version); err != nil {
return false
}
return strings.Contains(strings.ToLower(version), "mariadb")
}
// rowsToMaps 将查询结果按列名转为 map,用于列名/列数不固定的查询
func (s *App) rowsToMaps(rows *sql.Rows) ([]map[string]string, error) {
defer func() { _ = rows.Close() }()
columns, err := rows.Columns()
if err != nil {
return nil, err
}
result := make([]map[string]string, 0)
for rows.Next() {
values := make([]sql.NullString, len(columns))
scans := make([]any, len(columns))
for i := range values {
scans[i] = &values[i]
}
if err = rows.Scan(scans...); err != nil {
return nil, err
}
m := make(map[string]string, len(columns))
for i, column := range columns {
m[column] = values[i].String
}
result = append(result, m)
}
return result, rows.Err()
}
+137 -24
View File
@@ -5,39 +5,152 @@ 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"`
MaxConnections string `form:"max_connections" json:"max_connections"`
MaxConnectErrors string `form:"max_connect_errors" json:"max_connect_errors"`
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"`
TableOpenCache string `form:"table_open_cache" json:"table_open_cache"`
MaxAllowedPacket string `form:"max_allowed_packet" json:"max_allowed_packet"`
OpenFilesLimit string `form:"open_files_limit" json:"open_files_limit"`
TableOpenCache string `form:"table_open_cache" json:"table_open_cache"`
MaxAllowedPacket string `form:"max_allowed_packet" json:"max_allowed_packet"`
OpenFilesLimit string `form:"open_files_limit" json:"open_files_limit"`
// 性能调整
KeyBufferSize string `form:"key_buffer_size" json:"key_buffer_size"`
SortBufferSize string `form:"sort_buffer_size" json:"sort_buffer_size"`
ReadBufferSize string `form:"read_buffer_size" json:"read_buffer_size"`
ReadRndBufferSize string `form:"read_rnd_buffer_size" json:"read_rnd_buffer_size"`
JoinBufferSize string `form:"join_buffer_size" json:"join_buffer_size"`
ThreadCacheSize string `form:"thread_cache_size" json:"thread_cache_size"`
ThreadStack string `form:"thread_stack" json:"thread_stack"`
TmpTableSize string `form:"tmp_table_size" json:"tmp_table_size"`
MaxHeapTableSize string `form:"max_heap_table_size" json:"max_heap_table_size"`
MyisamSortBufferSize string `form:"myisam_sort_buffer_size" json:"myisam_sort_buffer_size"`
KeyBufferSize string `form:"key_buffer_size" json:"key_buffer_size"`
SortBufferSize string `form:"sort_buffer_size" json:"sort_buffer_size"`
ReadBufferSize string `form:"read_buffer_size" json:"read_buffer_size"`
ReadRndBufferSize string `form:"read_rnd_buffer_size" json:"read_rnd_buffer_size"`
JoinBufferSize string `form:"join_buffer_size" json:"join_buffer_size"`
ThreadCacheSize string `form:"thread_cache_size" json:"thread_cache_size"`
ThreadStack string `form:"thread_stack" json:"thread_stack"`
TmpTableSize string `form:"tmp_table_size" json:"tmp_table_size"`
MaxHeapTableSize string `form:"max_heap_table_size" json:"max_heap_table_size"`
MyisamSortBufferSize string `form:"myisam_sort_buffer_size" json:"myisam_sort_buffer_size"`
// 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"`
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"`
InnodbWriteIoThreads string `form:"innodb_write_io_threads" json:"innodb_write_io_threads"`
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" 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"`
InnodbWriteIoThreads string `form:"innodb_write_io_threads" json:"innodb_write_io_threads"`
// 日志
SlowQueryLog string `form:"slow_query_log" json:"slow_query_log"`
LongQueryTime string `form:"long_query_time" json:"long_query_time"`
}
// TableQuery 表维护列表查询请求
type TableQuery struct {
Database string `form:"database" json:"database"`
}
// MaintenanceTable 维护操作的目标表
type MaintenanceTable struct {
Database string `form:"database" json:"database" validate:"required"`
Table string `form:"table" json:"table" validate:"required"`
}
// MaintenanceRun 表维护操作请求
type MaintenanceRun struct {
Tables []MaintenanceTable `form:"tables" json:"tables"`
Operation string `form:"operation" json:"operation" validate:"required"`
}
// BinlogPurge binlog 清理请求
type BinlogPurge struct {
File string `form:"file" json:"file" validate:"required"`
}
// Process 数据库进程信息
type Process struct {
ID int64 `json:"id"`
User string `json:"user"`
Host string `json:"host"`
DB string `json:"db"`
Command string `json:"command"`
Time int64 `json:"time"`
State string `json:"state"`
Info string `json:"info"`
}
// Transaction InnoDB 事务信息
type Transaction struct {
ID string `json:"id"`
ThreadID int64 `json:"thread_id"`
State string `json:"state"`
Query string `json:"query"`
Seconds int64 `json:"seconds"`
RowsLocked int64 `json:"rows_locked"`
RowsModified int64 `json:"rows_modified"`
}
// LockWait 锁等待对
type LockWait struct {
WaitingThreadID int64 `json:"waiting_thread_id"`
WaitingQuery string `json:"waiting_query"`
BlockingThreadID int64 `json:"blocking_thread_id"`
BlockingQuery string `json:"blocking_query"`
}
// Transactions 事务与锁等待响应
type Transactions struct {
Transactions []Transaction `json:"transactions"`
LockWaits []LockWait `json:"lock_waits"`
}
// TopSQLItem Top SQL 统计项
type TopSQLItem struct {
Database string `json:"database"`
Calls int64 `json:"calls"`
TotalMs int64 `json:"total_ms"`
MeanMs float64 `json:"mean_ms"`
RowsSent int64 `json:"rows_sent"`
RowsExamined int64 `json:"rows_examined"`
Query string `json:"query"`
}
// TopSQL Top SQL 响应
type TopSQL struct {
Supported bool `json:"supported"`
Enabled bool `json:"enabled"`
PendingRestart bool `json:"pending_restart"`
Items []TopSQLItem `json:"items"`
}
// TableInfo 表维护信息
type TableInfo struct {
Database string `json:"database"`
Table string `json:"table"`
Engine string `json:"engine"`
Rows int64 `json:"rows"`
Size string `json:"size"`
SizeBytes int64 `json:"size_bytes"`
FragmentRate float64 `json:"fragment_rate"`
}
// BinlogFile binlog 文件信息
type BinlogFile struct {
Name string `json:"name"`
Size string `json:"size"`
SizeBytes int64 `json:"size_bytes"`
}
// Binlog binlog 状态响应
type Binlog struct {
Enabled bool `json:"enabled"`
TotalSize string `json:"total_size"`
Items []BinlogFile `json:"items"`
}
// Replication 复制状态响应
type Replication struct {
Enabled bool `json:"enabled"`
IORunning string `json:"io_running"`
SQLRunning string `json:"sql_running"`
SecondsBehind string `json:"seconds_behind"`
SourceHost string `json:"source_host"`
LastError string `json:"last_error"`
}
+69 -139
View File
@@ -4,21 +4,21 @@ import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-resty/resty/v2"
"github.com/leonelquinteros/gotext"
"github.com/spf13/cast"
"resty.dev/v3"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/pkg/tools"
"github.com/acepanel/panel/pkg/types"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/service"
"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/tools"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
@@ -26,6 +26,7 @@ type App struct {
}
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}
@@ -50,8 +51,13 @@ func (s *App) Route(r chi.Router) {
r.Delete("/stream/upstreams/{name}", s.DeleteStreamUpstream)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("nginx")
return types.AggregateAppStatus(ok)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/nginx/conf/nginx.conf", app.Root))
config, err := io.Read(app.Root + "/server/nginx/conf/nginx.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -67,7 +73,7 @@ func (s *App) SaveConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err = io.Write(fmt.Sprintf("%s/server/nginx/conf/nginx.conf", app.Root), req.Config, 0600); err != nil {
if err = io.Write(app.Root+"/server/nginx/conf/nginx.conf", req.Config, 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -96,8 +102,9 @@ func (s *App) ClearErrorLog(w http.ResponseWriter, r *http.Request) {
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
}
@@ -171,7 +178,7 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
// GetConfigTune 获取 Nginx 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/nginx/conf/nginx.conf", app.Root))
config, err := io.Read(app.Root + "/server/nginx/conf/nginx.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -179,33 +186,33 @@ func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
tune := ConfigTune{
// 常规设置
WorkerProcesses: s.getNginxValue(config, "worker_processes"),
WorkerConnections: s.getNginxValue(config, "worker_connections"),
KeepaliveTimeout: s.getNginxValue(config, "keepalive_timeout"),
ClientMaxBodySize: s.getNginxValue(config, "client_max_body_size"),
ClientBodyBufferSize: s.getNginxValue(config, "client_body_buffer_size"),
ClientHeaderBufferSize: s.getNginxValue(config, "client_header_buffer_size"),
ServerNamesHashBucketSize: s.getNginxValue(config, "server_names_hash_bucket_size"),
ServerTokens: s.getNginxValue(config, "server_tokens"),
WorkerProcesses: confval.Nginx.Get(config, "worker_processes"),
WorkerConnections: confval.Nginx.Get(config, "worker_connections"),
KeepaliveTimeout: confval.Nginx.Get(config, "keepalive_timeout"),
ClientMaxBodySize: confval.Nginx.Get(config, "client_max_body_size"),
ClientBodyBufferSize: confval.Nginx.Get(config, "client_body_buffer_size"),
ClientHeaderBufferSize: confval.Nginx.Get(config, "client_header_buffer_size"),
ServerNamesHashBucketSize: confval.Nginx.Get(config, "server_names_hash_bucket_size"),
ServerTokens: confval.Nginx.Get(config, "server_tokens"),
// Gzip 压缩
Gzip: s.getNginxValue(config, "gzip"),
GzipMinLength: s.getNginxValue(config, "gzip_min_length"),
GzipCompLevel: s.getNginxValue(config, "gzip_comp_level"),
GzipTypes: s.getNginxValue(config, "gzip_types"),
GzipVary: s.getNginxValue(config, "gzip_vary"),
GzipProxied: s.getNginxValue(config, "gzip_proxied"),
Gzip: confval.Nginx.Get(config, "gzip"),
GzipMinLength: confval.Nginx.Get(config, "gzip_min_length"),
GzipCompLevel: confval.Nginx.Get(config, "gzip_comp_level"),
GzipTypes: confval.Nginx.Get(config, "gzip_types"),
GzipVary: confval.Nginx.Get(config, "gzip_vary"),
GzipProxied: confval.Nginx.Get(config, "gzip_proxied"),
// Brotli 压缩
Brotli: s.getNginxValue(config, "brotli"),
BrotliMinLength: s.getNginxValue(config, "brotli_min_length"),
BrotliCompLevel: s.getNginxValue(config, "brotli_comp_level"),
BrotliTypes: s.getNginxValue(config, "brotli_types"),
BrotliStatic: s.getNginxValue(config, "brotli_static"),
Brotli: confval.Nginx.Get(config, "brotli"),
BrotliMinLength: confval.Nginx.Get(config, "brotli_min_length"),
BrotliCompLevel: confval.Nginx.Get(config, "brotli_comp_level"),
BrotliTypes: confval.Nginx.Get(config, "brotli_types"),
BrotliStatic: confval.Nginx.Get(config, "brotli_static"),
// Zstd 压缩
Zstd: s.getNginxValue(config, "zstd"),
ZstdMinLength: s.getNginxValue(config, "zstd_min_length"),
ZstdCompLevel: s.getNginxValue(config, "zstd_comp_level"),
ZstdTypes: s.getNginxValue(config, "zstd_types"),
ZstdStatic: s.getNginxValue(config, "zstd_static"),
Zstd: confval.Nginx.Get(config, "zstd"),
ZstdMinLength: confval.Nginx.Get(config, "zstd_min_length"),
ZstdCompLevel: confval.Nginx.Get(config, "zstd_comp_level"),
ZstdTypes: confval.Nginx.Get(config, "zstd_types"),
ZstdStatic: confval.Nginx.Get(config, "zstd_static"),
}
service.Success(w, tune)
@@ -219,7 +226,7 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
return
}
confPath := fmt.Sprintf("%s/server/nginx/conf/nginx.conf", app.Root)
confPath := app.Root + "/server/nginx/conf/nginx.conf"
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -227,33 +234,33 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
}
// 更新常规设置
config = s.setNginxValue(config, "worker_processes", req.WorkerProcesses)
config = s.setNginxValue(config, "worker_connections", req.WorkerConnections)
config = s.setNginxValue(config, "keepalive_timeout", req.KeepaliveTimeout)
config = s.setNginxValue(config, "client_max_body_size", req.ClientMaxBodySize)
config = s.setNginxValue(config, "client_body_buffer_size", req.ClientBodyBufferSize)
config = s.setNginxValue(config, "client_header_buffer_size", req.ClientHeaderBufferSize)
config = s.setNginxValue(config, "server_names_hash_bucket_size", req.ServerNamesHashBucketSize)
config = s.setNginxValue(config, "server_tokens", req.ServerTokens)
config = confval.Nginx.Set(config, "worker_processes", req.WorkerProcesses)
config = confval.Nginx.Set(config, "worker_connections", req.WorkerConnections)
config = confval.Nginx.Set(config, "keepalive_timeout", req.KeepaliveTimeout)
config = confval.Nginx.Set(config, "client_max_body_size", req.ClientMaxBodySize)
config = confval.Nginx.Set(config, "client_body_buffer_size", req.ClientBodyBufferSize)
config = confval.Nginx.Set(config, "client_header_buffer_size", req.ClientHeaderBufferSize)
config = confval.Nginx.Set(config, "server_names_hash_bucket_size", req.ServerNamesHashBucketSize)
config = confval.Nginx.Set(config, "server_tokens", req.ServerTokens)
// 更新 Gzip 压缩
config = s.setNginxValue(config, "gzip", req.Gzip)
config = s.setNginxValue(config, "gzip_min_length", req.GzipMinLength)
config = s.setNginxValue(config, "gzip_comp_level", req.GzipCompLevel)
config = s.setNginxValue(config, "gzip_types", req.GzipTypes)
config = s.setNginxValue(config, "gzip_vary", req.GzipVary)
config = s.setNginxValue(config, "gzip_proxied", req.GzipProxied)
config = confval.Nginx.Set(config, "gzip", req.Gzip)
config = confval.Nginx.Set(config, "gzip_min_length", req.GzipMinLength)
config = confval.Nginx.Set(config, "gzip_comp_level", req.GzipCompLevel)
config = confval.Nginx.Set(config, "gzip_types", req.GzipTypes)
config = confval.Nginx.Set(config, "gzip_vary", req.GzipVary)
config = confval.Nginx.Set(config, "gzip_proxied", req.GzipProxied)
// 更新 Brotli 压缩
config = s.setNginxValue(config, "brotli", req.Brotli)
config = s.setNginxValue(config, "brotli_min_length", req.BrotliMinLength)
config = s.setNginxValue(config, "brotli_comp_level", req.BrotliCompLevel)
config = s.setNginxValue(config, "brotli_types", req.BrotliTypes)
config = s.setNginxValue(config, "brotli_static", req.BrotliStatic)
config = confval.Nginx.Set(config, "brotli", req.Brotli)
config = confval.Nginx.Set(config, "brotli_min_length", req.BrotliMinLength)
config = confval.Nginx.Set(config, "brotli_comp_level", req.BrotliCompLevel)
config = confval.Nginx.Set(config, "brotli_types", req.BrotliTypes)
config = confval.Nginx.Set(config, "brotli_static", req.BrotliStatic)
// 更新 Zstd 压缩
config = s.setNginxValue(config, "zstd", req.Zstd)
config = s.setNginxValue(config, "zstd_min_length", req.ZstdMinLength)
config = s.setNginxValue(config, "zstd_comp_level", req.ZstdCompLevel)
config = s.setNginxValue(config, "zstd_types", req.ZstdTypes)
config = s.setNginxValue(config, "zstd_static", req.ZstdStatic)
config = confval.Nginx.Set(config, "zstd", req.Zstd)
config = confval.Nginx.Set(config, "zstd_min_length", req.ZstdMinLength)
config = confval.Nginx.Set(config, "zstd_comp_level", req.ZstdCompLevel)
config = confval.Nginx.Set(config, "zstd_types", req.ZstdTypes)
config = confval.Nginx.Set(config, "zstd_static", req.ZstdStatic)
if err = io.Write(confPath, config, 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -268,80 +275,3 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
// getNginxValue 从 Nginx 配置内容中获取指定指令的值
func (s *App) getNginxValue(content string, key string) string {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if !strings.HasSuffix(trimmed, ";") {
continue
}
trimmed = strings.TrimSuffix(trimmed, ";")
trimmed = strings.TrimSpace(trimmed)
parts := strings.Fields(trimmed)
if len(parts) >= 2 && parts[0] == key {
return strings.Join(parts[1:], " ")
}
}
return ""
}
// setNginxValue 在 Nginx 配置内容中设置指定指令的值
func (s *App) setNginxValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
lines := strings.Split(content, "\n")
found := false
result := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || trimmed == "{" || trimmed == "}" {
result = append(result, line)
continue
}
// 检查指令(可能被注释)
checkLine := trimmed
if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
}
if !strings.HasSuffix(checkLine, ";") {
result = append(result, line)
continue
}
checkLine = strings.TrimSuffix(checkLine, ";")
checkLine = strings.TrimSpace(checkLine)
parts := strings.Fields(checkLine)
if len(parts) >= 2 && parts[0] == key {
if found {
continue
}
found = true
// 值为空时注释掉该配置项
if value == "" {
if !strings.HasPrefix(trimmed, "#") {
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
result = append(result, indent+"#"+strings.TrimLeft(line, " \t"))
} else {
result = append(result, line)
}
continue
}
// 保留原行缩进
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
result = append(result, indent+key+" "+value+";")
} else {
result = append(result, line)
}
}
if !found && value != "" {
result = append(result, " "+key+" "+value+";")
}
return strings.Join(result, "\n")
}
+22 -22
View File
@@ -9,14 +9,14 @@ type UpdateConfig struct {
// ConfigTune Nginx 配置调整
type ConfigTune struct {
// 常规设置
WorkerProcesses string `form:"worker_processes" json:"worker_processes"`
WorkerConnections string `form:"worker_connections" json:"worker_connections"`
KeepaliveTimeout string `form:"keepalive_timeout" json:"keepalive_timeout"`
ClientMaxBodySize string `form:"client_max_body_size" json:"client_max_body_size"`
ClientBodyBufferSize string `form:"client_body_buffer_size" json:"client_body_buffer_size"`
ClientHeaderBufferSize string `form:"client_header_buffer_size" json:"client_header_buffer_size"`
WorkerProcesses string `form:"worker_processes" json:"worker_processes"`
WorkerConnections string `form:"worker_connections" json:"worker_connections"`
KeepaliveTimeout string `form:"keepalive_timeout" json:"keepalive_timeout"`
ClientMaxBodySize string `form:"client_max_body_size" json:"client_max_body_size"`
ClientBodyBufferSize string `form:"client_body_buffer_size" json:"client_body_buffer_size"`
ClientHeaderBufferSize string `form:"client_header_buffer_size" json:"client_header_buffer_size"`
ServerNamesHashBucketSize string `form:"server_names_hash_bucket_size" json:"server_names_hash_bucket_size"`
ServerTokens string `form:"server_tokens" json:"server_tokens"`
ServerTokens string `form:"server_tokens" json:"server_tokens"`
// Gzip 压缩
Gzip string `form:"gzip" json:"gzip"`
GzipMinLength string `form:"gzip_min_length" json:"gzip_min_length"`
@@ -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 解析超时时间
}
+22 -23
View File
@@ -1,6 +1,7 @@
package nginx
import (
"errors"
"fmt"
"net/http"
"os"
@@ -10,11 +11,12 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/samber/lo"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/systemctl"
webserverNginx "github.com/acepanel/panel/pkg/webserver/nginx"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/systemctl"
webserverNginx "github.com/acepanel/panel/v3/pkg/webserver/nginx"
)
// ListStreamServers 获取 Stream Server 列表
@@ -35,7 +37,7 @@ func (s *App) CreateStreamServer(w http.ResponseWriter, r *http.Request) {
return
}
configPath := filepath.Join(s.streamDir(), fmt.Sprintf("%s.conf", req.Name))
configPath := filepath.Join(s.streamDir(), req.Name+".conf")
if _, statErr := os.Stat(configPath); statErr == nil {
service.Error(w, http.StatusConflict, s.t.Get("stream server config already exists: %s", req.Name))
return
@@ -69,7 +71,7 @@ func (s *App) UpdateStreamServer(w http.ResponseWriter, r *http.Request) {
return
}
configPath := filepath.Join(s.streamDir(), fmt.Sprintf("%s.conf", name))
configPath := filepath.Join(s.streamDir(), name+".conf")
if _, statErr := os.Stat(configPath); os.IsNotExist(statErr) {
service.Error(w, http.StatusNotFound, s.t.Get("stream server not found: %s", name))
return
@@ -77,7 +79,7 @@ func (s *App) UpdateStreamServer(w http.ResponseWriter, r *http.Request) {
newConfigPath := configPath
if req.Name != name {
newConfigPath = filepath.Join(s.streamDir(), fmt.Sprintf("%s.conf", req.Name))
newConfigPath = filepath.Join(s.streamDir(), req.Name+".conf")
if _, statErr := os.Stat(newConfigPath); statErr == nil {
service.Error(w, http.StatusConflict, s.t.Get("stream server config already exists: %s", req.Name))
return
@@ -109,7 +111,7 @@ func (s *App) DeleteStreamServer(w http.ResponseWriter, r *http.Request) {
return
}
configPath := filepath.Join(s.streamDir(), fmt.Sprintf("%s.conf", name))
configPath := filepath.Join(s.streamDir(), name+".conf")
if _, statErr := os.Stat(configPath); os.IsNotExist(statErr) {
service.Error(w, http.StatusNotFound, s.t.Get("stream server not found: %s", name))
return
@@ -410,24 +412,24 @@ func (s *App) parseStreamUpstreamFile(filePath string, expectedName string) (*St
cfg := p.Config()
if cfg == nil || cfg.Block == nil {
return nil, fmt.Errorf("invalid config")
return nil, errors.New("invalid config")
}
// 查找 upstream 块
upstreamDirectives := cfg.Block.FindDirectives("upstream")
if len(upstreamDirectives) == 0 {
return nil, fmt.Errorf("no upstream block found")
return nil, errors.New("no upstream block found")
}
upstreamDir := upstreamDirectives[0]
params := upstreamDir.GetParameters()
if len(params) == 0 {
return nil, fmt.Errorf("upstream name not found")
return nil, errors.New("upstream name not found")
}
name := params[0].Value
if expectedName != "" && name != expectedName {
return nil, fmt.Errorf("upstream name mismatch")
return nil, errors.New("upstream name mismatch")
}
upstream := &StreamUpstream{
@@ -438,7 +440,7 @@ func (s *App) parseStreamUpstreamFile(filePath string, expectedName string) (*St
upstreamBlock := upstreamDir.GetBlock()
if upstreamBlock == nil {
return nil, fmt.Errorf("upstream block is empty")
return nil, errors.New("upstream block is empty")
}
// 解析 upstream 块中的指令
@@ -547,34 +549,31 @@ func (s *App) saveStreamServerConfig(filePath string, server *StreamServer) erro
// saveStreamUpstreamConfig 生成并保存 Stream Upstream 配置
func (s *App) saveStreamUpstreamConfig(filePath string, upstream *StreamUpstream) error {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("upstream %s {\n", upstream.Name))
_, _ = fmt.Fprintf(&sb, "upstream %s {\n", upstream.Name)
// 负载均衡算法
if upstream.Algo != "" {
sb.WriteString(fmt.Sprintf(" %s;\n", upstream.Algo))
_, _ = fmt.Fprintf(&sb, " %s;\n", upstream.Algo)
}
// resolver 配置
if len(upstream.Resolver) > 0 {
sb.WriteString(fmt.Sprintf(" resolver %s;\n", strings.Join(upstream.Resolver, " ")))
_, _ = fmt.Fprintf(&sb, " resolver %s;\n", strings.Join(upstream.Resolver, " "))
if upstream.ResolverTimeout > 0 {
sb.WriteString(fmt.Sprintf(" resolver_timeout %s;\n", formatNginxDuration(upstream.ResolverTimeout)))
_, _ = fmt.Fprintf(&sb, " resolver_timeout %s;\n", formatNginxDuration(upstream.ResolverTimeout))
}
}
// 服务器列表
var addrs []string
for addr := range upstream.Servers {
addrs = append(addrs, addr)
}
addrs := lo.Keys(upstream.Servers)
sort.Strings(addrs)
for _, addr := range addrs {
options := upstream.Servers[addr]
if options != "" {
sb.WriteString(fmt.Sprintf(" server %s %s;\n", addr, options))
_, _ = fmt.Fprintf(&sb, " server %s %s;\n", addr, options)
} else {
sb.WriteString(fmt.Sprintf(" server %s;\n", addr))
_, _ = fmt.Fprintf(&sb, " server %s;\n", addr)
}
}
+10 -4
View File
@@ -2,21 +2,27 @@ package openresty
import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/internal/apps/nginx"
"github.com/acepanel/panel/v3/internal/apps/nginx"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
nginx *nginx.App
}
func NewApp(t *gotext.Locale) *App {
func NewApp(nginxApp *nginx.App) *App {
return &App{
nginx: nginx.NewApp(t),
nginx: nginxApp,
}
}
func (s *App) Route(r chi.Router) {
s.nginx.Route(r)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("nginx")
return types.AggregateAppStatus(ok)
}
+230
View File
@@ -0,0 +1,230 @@
package opensearch
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"time"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/spf13/cast"
"go.yaml.in/yaml/v4"
"resty.dev/v3"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
r.Get("/load", s.Load)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("opensearch")
return types.AggregateAppStatus(ok)
}
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, err := systemctl.Status("opensearch")
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get opensearch status: %v", err))
return
}
if !status {
service.Success(w, []types.NV{})
return
}
port := s.getPort()
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.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
var health struct {
ClusterName string `json:"cluster_name"`
Status string `json:"status"`
NumberOfNodes int `json:"number_of_nodes"`
NumberOfDataNodes int `json:"number_of_data_nodes"`
ActiveShards int `json:"active_shards"`
ActivePrimaryShards int `json:"active_primary_shards"`
RelocatingShards int `json:"relocating_shards"`
UnassignedShards int `json:"unassigned_shards"`
}
if err = json.Unmarshal(resp.Bytes(), &health); err != nil {
service.Success(w, []types.NV{})
return
}
data := []types.NV{
{Name: s.t.Get("Cluster Name"), Value: health.ClusterName},
{Name: s.t.Get("Cluster Status"), Value: health.Status},
{Name: s.t.Get("Number of Nodes"), Value: cast.ToString(health.NumberOfNodes)},
{Name: s.t.Get("Number of Data Nodes"), Value: cast.ToString(health.NumberOfDataNodes)},
{Name: s.t.Get("Active Shards"), Value: cast.ToString(health.ActiveShards)},
{Name: s.t.Get("Active Primary Shards"), Value: cast.ToString(health.ActivePrimaryShards)},
{Name: s.t.Get("Relocating Shards"), Value: cast.ToString(health.RelocatingShards)},
{Name: s.t.Get("Unassigned Shards"), Value: cast.ToString(health.UnassignedShards)},
}
service.Success(w, data)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
common.ServeConfig(w, s.configPath())
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
common.SaveConfig(w, r, s.configPath(), "opensearch")
}
// GetConfigTune 获取 OpenSearch 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
raw, _ := io.Read(s.configPath())
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg == nil {
cfg = make(map[string]any)
}
jvmRaw, _ := io.Read(s.jvmOptionsPath())
heapInit, heapMax := s.parseJVMHeap(jvmRaw)
tune := ConfigTune{
ClusterName: confval.GetYAML(cfg, "cluster.name"),
NodeName: confval.GetYAML(cfg, "node.name"),
NetworkHost: confval.GetYAML(cfg, "network.host"),
HTTPPort: confval.GetYAML(cfg, "http.port"),
DiscoveryType: confval.GetYAML(cfg, "discovery.type"),
PathData: confval.GetYAML(cfg, "path.data"),
PathLogs: confval.GetYAML(cfg, "path.logs"),
HeapInitSize: heapInit,
HeapMaxSize: heapMax,
}
service.Success(w, tune)
}
// UpdateConfigTune 更新 OpenSearch 配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
raw, _ := io.Read(s.configPath())
var cfg map[string]any
if err = yaml.Unmarshal([]byte(raw), &cfg); err != nil {
cfg = make(map[string]any)
}
confval.SetYAML(cfg, "cluster.name", req.ClusterName)
confval.SetYAML(cfg, "node.name", req.NodeName)
confval.SetYAML(cfg, "network.host", req.NetworkHost)
confval.SetYAML(cfg, "http.port", req.HTTPPort)
confval.SetYAML(cfg, "discovery.type", req.DiscoveryType)
confval.SetYAML(cfg, "path.data", req.PathData)
confval.SetYAML(cfg, "path.logs", req.PathLogs)
data, err := yaml.Marshal(cfg)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(s.configPath(), string(data), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if req.HeapInitSize != "" || req.HeapMaxSize != "" {
jvmRaw, _ := io.Read(s.jvmOptionsPath())
jvmRaw = s.setJVMHeap(jvmRaw, req.HeapInitSize, req.HeapMaxSize)
if err = io.Write(s.jvmOptionsPath(), jvmRaw, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
if err = systemctl.Restart("opensearch"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
func (s *App) configPath() string {
return app.Root + "/server/opensearch/config/opensearch.yml"
}
func (s *App) jvmOptionsPath() string {
return app.Root + "/server/opensearch/config/jvm.options"
}
func (s *App) getPort() string {
raw, _ := io.Read(s.configPath())
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg != nil {
if v := confval.GetYAML(cfg, "http.port"); v != "" {
return v
}
}
return "9200"
}
func (s *App) parseJVMHeap(content string) (initSize, maxSize string) {
reInit := regexp.MustCompile(`(?m)^-Xms(\S+)`)
reMax := regexp.MustCompile(`(?m)^-Xmx(\S+)`)
if m := reInit.FindStringSubmatch(content); len(m) == 2 {
initSize = m[1]
}
if m := reMax.FindStringSubmatch(content); len(m) == 2 {
maxSize = m[1]
}
return
}
func (s *App) setJVMHeap(content string, initSize, maxSize string) string {
if initSize != "" {
re := regexp.MustCompile(`(?m)^-Xms\S+`)
if re.MatchString(content) {
content = re.ReplaceAllString(content, "-Xms"+initSize)
} else {
content += "\n-Xms" + initSize
}
}
if maxSize != "" {
re := regexp.MustCompile(`(?m)^-Xmx\S+`)
if re.MatchString(content) {
content = re.ReplaceAllString(content, "-Xmx"+maxSize)
} else {
content += "\n-Xmx" + maxSize
}
}
return content
}
+17
View File
@@ -0,0 +1,17 @@
package opensearch
// ConfigTune OpenSearch 配置调整
type ConfigTune struct {
// 集群
ClusterName string `form:"cluster_name" json:"cluster_name"`
NodeName string `form:"node_name" json:"node_name"`
NetworkHost string `form:"network_host" json:"network_host"`
HTTPPort string `form:"http_port" json:"http_port"`
DiscoveryType string `form:"discovery_type" json:"discovery_type"`
// 路径
PathData string `form:"path_data" json:"path_data"`
PathLogs string `form:"path_logs" json:"path_logs"`
// JVM
HeapInitSize string `form:"heap_init_size" json:"heap_init_size"`
HeapMaxSize string `form:"heap_max_size" json:"heap_max_size"`
}
+7 -5
View File
@@ -2,22 +2,24 @@ package percona
import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/internal/apps/mysql"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/v3/internal/apps/mysql"
)
type App struct {
mysql *mysql.App
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(mysqlApp *mysql.App) *App {
return &App{
mysql: mysql.NewApp(t, setting, databaseServer),
mysql: mysqlApp,
}
}
func (s *App) Route(r chi.Router) {
s.mysql.Route(r)
}
func (s *App) Status() string {
return s.mysql.Status()
}
+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 {
return &App{
t: t,
conf: conf,
databaseServerRepo: databaseServerRepo,
}
}
// 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 app.Root + "/server/pgadmin"
}
// port 从 systemd 环境文件中解析监听端口
func (s *App) port() (uint, error) {
conf, err := io.Read(s.path() + "/pgadmin.conf")
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(s.path() + "/credential")
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 := s.path() + "/pgadmin.conf"
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(s.path()+"/credential", 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(s.path()+"/credential", 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"`
}
+196 -27
View File
@@ -1,47 +1,67 @@
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/internal/app"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/firewall"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"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
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 {
return &App{
t: t,
t: t,
conf: conf,
databaseServerRepo: databaseServerRepo,
}
}
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)
}
func (s *App) Info(w http.ResponseWriter, r *http.Request) {
files, err := os.ReadDir(fmt.Sprintf("%s/server/phpmyadmin", app.Root))
// Status phpMyAdmin 由 nginx 站点承载,运行状态与 nginx 一致
func (s *App) Status() string {
ok, _ := systemctl.Status("nginx")
return types.AggregateAppStatus(ok)
}
// info 获取 phpMyAdmin 的访问目录与端口
func (s *App) info() (string, int, error) {
files, err := os.ReadDir(app.Root + "/server/phpmyadmin")
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
@@ -51,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))
conf, err := io.Read(app.Root + "/sites/phpmyadmin/config/nginx.conf")
if err != nil {
return "", 0, err
}
match := regexp.MustCompile(`listen\s+(\d+);`).FindStringSubmatch(conf)
if len(match) == 0 {
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
}
match := regexp.MustCompile(`listen\s+(\d+);`).FindStringSubmatch(conf)
if len(match) == 0 {
service.Error(w, http.StatusInternalServerError, s.t.Get("phpMyAdmin port not found"))
service.Success(w, chix.M{
"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": phpmyadmin,
"port": cast.ToInt(match[1]),
"path": path,
"port": port,
})
}
@@ -79,13 +248,13 @@ func (s *App) UpdatePort(w http.ResponseWriter, r *http.Request) {
return
}
conf, err := io.Read(fmt.Sprintf("%s/sites/phpmyadmin/config/nginx.conf", app.Root))
conf, err := io.Read(app.Root + "/sites/phpmyadmin/config/nginx.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
conf = regexp.MustCompile(`listen\s+(\d+);`).ReplaceAllString(conf, "listen "+cast.ToString(req.Port)+";")
if err = io.Write(fmt.Sprintf("%s/sites/phpmyadmin/config/nginx.conf", app.Root), conf, 0600); err != nil {
if err = io.Write(app.Root+"/sites/phpmyadmin/config/nginx.conf", conf, 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -95,8 +264,8 @@ func (s *App) UpdatePort(w http.ResponseWriter, r *http.Request) {
Type: firewall.TypeNormal,
PortStart: req.Port,
PortEnd: req.Port,
Direction: firewall.DirectionIn,
Strategy: firewall.StrategyAccept,
Direction: firewall.DirectionIn,
}, firewall.OperationAdd)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -113,7 +282,7 @@ func (s *App) UpdatePort(w http.ResponseWriter, r *http.Request) {
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/sites/phpmyadmin/config/nginx.conf", app.Root))
config, err := io.Read(app.Root + "/sites/phpmyadmin/config/nginx.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -129,7 +298,7 @@ func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err = io.Write(fmt.Sprintf("%s/sites/phpmyadmin/config/nginx.conf", app.Root), req.Config, 0600); err != nil {
if err = io.Write(app.Root+"/sites/phpmyadmin/config/nginx.conf", req.Config, 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
+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"`
}
+9 -3
View File
@@ -5,9 +5,10 @@ import (
"github.com/go-chi/chi/v5"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct{}
@@ -23,6 +24,11 @@ func (s *App) Route(r chi.Router) {
r.Post("/storage_config", s.UpdateStorageConfig)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("podman")
return types.AggregateAppStatus(ok)
}
func (s *App) GetRegistryConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read("/etc/containers/registries.conf")
if err != nil {
+660 -156
View File
@@ -1,36 +1,49 @@
package postgresql
import (
"context"
"fmt"
"net/http"
"os"
"net/url"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/acepanel/panel/pkg/db"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/spf13/cast"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/pkg/types"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/confval"
"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/db"
"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"
)
var defaultVersionRegexp = regexp.MustCompile(`default_version\s*=\s*'([^']+)'`)
type App struct {
t *gotext.Locale
conf *config.Config
settingRepo biz.SettingRepo
databaseServerRepo biz.DatabaseServerRepo
taskRepo biz.TaskRepo
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(t *gotext.Locale, conf *config.Config, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo, taskRepo biz.TaskRepo) *App {
return &App{
t: t,
settingRepo: setting,
databaseServerRepo: databaseServer,
conf: conf,
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
taskRepo: taskRepo,
}
}
@@ -41,17 +54,38 @@ func (s *App) Route(r chi.Router) {
r.Post("/user_config", s.UpdateUserConfig)
r.Get("/load", s.Load)
r.Get("/log", s.Log)
r.Post("/clear_log", s.ClearLog)
r.Get("/postgres_password", s.GetPostgresPassword)
r.Post("/postgres_password", s.SetPostgresPassword)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
// 扩展管理
r.Get("/extensions", s.ExtensionList)
r.Post("/extensions", s.InstallExtension)
r.Delete("/extensions", s.UninstallExtension)
r.Post("/extensions/enable", s.EnableExtension)
// 性能
r.Get("/sessions", s.SessionList)
r.Post("/sessions/{pid}/terminate", s.TerminateSession)
r.Get("/top_sql", s.TopSQL)
r.Post("/top_sql/enable", s.EnableTopSQL)
r.Post("/top_sql/reset", s.ResetTopSQL)
// 维护
r.Get("/databases", s.DatabaseList)
r.Get("/bloat", s.BloatList)
r.Post("/maintenance", s.RunMaintenance)
r.Get("/wal", s.WalStatus)
r.Delete("/replication_slots/{slot}", s.DropReplicationSlot)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("postgresql")
return types.AggregateAppStatus(ok)
}
// 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
@@ -68,13 +102,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 := db.PostgresPort(app.Root)
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
}
@@ -84,7 +119,7 @@ func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
// GetUserConfig 获取用户配置
func (s *App) GetUserConfig(w http.ResponseWriter, r *http.Request) {
// 获取配置
config, err := io.Read(fmt.Sprintf("%s/server/postgresql/data/pg_hba.conf", app.Root))
config, err := io.Read(app.Root + "/server/postgresql/data/pg_hba.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -101,7 +136,7 @@ func (s *App) UpdateUserConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err = io.Write(fmt.Sprintf("%s/server/postgresql/data/pg_hba.conf", app.Root), req.Config, 0644); err != nil {
if err = io.Write(app.Root+"/server/postgresql/data/pg_hba.conf", req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -128,17 +163,14 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
return
}
if err = os.Setenv("PGPASSWORD", postgresPassword); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to set PGPASSWORD env: %v", err))
return
}
start, err := shell.Execf(`psql -h 127.0.0.1 -U postgres -t -c "select pg_postmaster_start_time();" | head -1 | cut -d'.' -f1`)
env := []string{"PGPASSWORD=" + postgresPassword}
port := db.PostgresPort(app.Root)
start, err := shell.ExecfWithEnv(env, `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.ExecfWithEnv(env, `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
@@ -148,22 +180,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.ExecfWithEnv(env, `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.ExecfWithEnv(env, `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},
@@ -175,19 +202,15 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
service.Success(w, data)
}
// Log 获取日志
// Log 获取应用日志路径列表
func (s *App) Log(w http.ResponseWriter, r *http.Request) {
service.Success(w, fmt.Sprintf("%s/server/postgresql/logs/postgresql-%s.log", app.Root, time.Now().Format(time.DateOnly)))
}
// ClearLog 清空日志
func (s *App) ClearLog(w http.ResponseWriter, r *http.Request) {
if _, err := shell.Execf("rm -rf %s/server/postgresql/logs/postgresql-*.log", app.Root); err != nil {
paths, err := filepath.Glob(app.Root + "/server/postgresql/logs/postgresql-*.log")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
service.Success(w, paths)
}
// GetPostgresPassword 获取 postgres 用户密码
@@ -210,10 +233,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 := db.PostgresPort(app.Root)
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 +261,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
@@ -245,32 +269,32 @@ func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
tune := ConfigTune{
// 连接设置
ListenAddresses: s.getPGValue(config, "listen_addresses"),
Port: s.getPGValue(config, "port"),
MaxConnections: s.getPGValue(config, "max_connections"),
SuperuserReservedConnections: s.getPGValue(config, "superuser_reserved_connections"),
ListenAddresses: confval.Postgres.Get(config, "listen_addresses"),
Port: confval.Postgres.Get(config, "port"),
MaxConnections: confval.Postgres.Get(config, "max_connections"),
SuperuserReservedConnections: confval.Postgres.Get(config, "superuser_reserved_connections"),
// 内存设置
SharedBuffers: s.getPGValue(config, "shared_buffers"),
WorkMem: s.getPGValue(config, "work_mem"),
MaintenanceWorkMem: s.getPGValue(config, "maintenance_work_mem"),
EffectiveCacheSize: s.getPGValue(config, "effective_cache_size"),
HugePages: s.getPGValue(config, "huge_pages"),
SharedBuffers: confval.Postgres.Get(config, "shared_buffers"),
WorkMem: confval.Postgres.Get(config, "work_mem"),
MaintenanceWorkMem: confval.Postgres.Get(config, "maintenance_work_mem"),
EffectiveCacheSize: confval.Postgres.Get(config, "effective_cache_size"),
HugePages: confval.Postgres.Get(config, "huge_pages"),
// WAL 设置
WalLevel: s.getPGValue(config, "wal_level"),
WalBuffers: s.getPGValue(config, "wal_buffers"),
MaxWalSize: s.getPGValue(config, "max_wal_size"),
MinWalSize: s.getPGValue(config, "min_wal_size"),
CheckpointCompletionTarget: s.getPGValue(config, "checkpoint_completion_target"),
WalLevel: confval.Postgres.Get(config, "wal_level"),
WalBuffers: confval.Postgres.Get(config, "wal_buffers"),
MaxWalSize: confval.Postgres.Get(config, "max_wal_size"),
MinWalSize: confval.Postgres.Get(config, "min_wal_size"),
CheckpointCompletionTarget: confval.Postgres.Get(config, "checkpoint_completion_target"),
// 查询优化
DefaultStatisticsTarget: s.getPGValue(config, "default_statistics_target"),
RandomPageCost: s.getPGValue(config, "random_page_cost"),
EffectiveIoConcurrency: s.getPGValue(config, "effective_io_concurrency"),
DefaultStatisticsTarget: confval.Postgres.Get(config, "default_statistics_target"),
RandomPageCost: confval.Postgres.Get(config, "random_page_cost"),
EffectiveIoConcurrency: confval.Postgres.Get(config, "effective_io_concurrency"),
// 日志设置
LogDestination: s.getPGValue(config, "log_destination"),
LogMinDurationStatement: s.getPGValue(config, "log_min_duration_statement"),
LogTimezone: s.getPGValue(config, "log_timezone"),
LogDestination: confval.Postgres.Get(config, "log_destination"),
LogMinDurationStatement: confval.Postgres.Get(config, "log_min_duration_statement"),
LogTimezone: confval.Postgres.Get(config, "log_timezone"),
// IO 设置
IoMethod: s.getPGValue(config, "io_method"),
IoMethod: confval.Postgres.Get(config, "io_method"),
}
service.Success(w, tune)
@@ -284,7 +308,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 := db.PostgresPort(app.Root)
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -292,118 +317,597 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
}
// 更新连接设置
config = s.setPGValue(config, "listen_addresses", req.ListenAddresses)
config = s.setPGValue(config, "port", req.Port)
config = s.setPGValue(config, "max_connections", req.MaxConnections)
config = s.setPGValue(config, "superuser_reserved_connections", req.SuperuserReservedConnections)
config = confval.Postgres.Set(config, "listen_addresses", req.ListenAddresses)
config = confval.Postgres.Set(config, "port", req.Port)
config = confval.Postgres.Set(config, "max_connections", req.MaxConnections)
config = confval.Postgres.Set(config, "superuser_reserved_connections", req.SuperuserReservedConnections)
// 更新内存设置
config = s.setPGValue(config, "shared_buffers", req.SharedBuffers)
config = s.setPGValue(config, "work_mem", req.WorkMem)
config = s.setPGValue(config, "maintenance_work_mem", req.MaintenanceWorkMem)
config = s.setPGValue(config, "effective_cache_size", req.EffectiveCacheSize)
config = s.setPGValue(config, "huge_pages", req.HugePages)
config = confval.Postgres.Set(config, "shared_buffers", req.SharedBuffers)
config = confval.Postgres.Set(config, "work_mem", req.WorkMem)
config = confval.Postgres.Set(config, "maintenance_work_mem", req.MaintenanceWorkMem)
config = confval.Postgres.Set(config, "effective_cache_size", req.EffectiveCacheSize)
config = confval.Postgres.Set(config, "huge_pages", req.HugePages)
// 更新 WAL 设置
config = s.setPGValue(config, "wal_level", req.WalLevel)
config = s.setPGValue(config, "wal_buffers", req.WalBuffers)
config = s.setPGValue(config, "max_wal_size", req.MaxWalSize)
config = s.setPGValue(config, "min_wal_size", req.MinWalSize)
config = s.setPGValue(config, "checkpoint_completion_target", req.CheckpointCompletionTarget)
config = confval.Postgres.Set(config, "wal_level", req.WalLevel)
config = confval.Postgres.Set(config, "wal_buffers", req.WalBuffers)
config = confval.Postgres.Set(config, "max_wal_size", req.MaxWalSize)
config = confval.Postgres.Set(config, "min_wal_size", req.MinWalSize)
config = confval.Postgres.Set(config, "checkpoint_completion_target", req.CheckpointCompletionTarget)
// 更新查询优化
config = s.setPGValue(config, "default_statistics_target", req.DefaultStatisticsTarget)
config = s.setPGValue(config, "random_page_cost", req.RandomPageCost)
config = s.setPGValue(config, "effective_io_concurrency", req.EffectiveIoConcurrency)
config = confval.Postgres.Set(config, "default_statistics_target", req.DefaultStatisticsTarget)
config = confval.Postgres.Set(config, "random_page_cost", req.RandomPageCost)
config = confval.Postgres.Set(config, "effective_io_concurrency", req.EffectiveIoConcurrency)
// 更新日志设置
config = s.setPGValue(config, "log_destination", req.LogDestination)
config = s.setPGValue(config, "log_min_duration_statement", req.LogMinDurationStatement)
config = s.setPGValue(config, "log_timezone", req.LogTimezone)
config = confval.Postgres.Set(config, "log_destination", req.LogDestination)
config = confval.Postgres.Set(config, "log_min_duration_statement", req.LogMinDurationStatement)
config = confval.Postgres.Set(config, "log_timezone", req.LogTimezone)
// 更新 IO 设置
config = s.setPGValue(config, "io_method", req.IoMethod)
config = confval.Postgres.Set(config, "io_method", req.IoMethod)
if err = io.Write(confPath, 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(config, oldPort); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to apply PostgreSQL config: %v", err))
return
}
service.Success(w, nil)
}
// getPGValue 从 PostgreSQL 配置内容中获取指定键的值
func (s *App) getPGValue(content string, key string) string {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
continue
}
k := strings.TrimSpace(parts[0])
if k == key {
v := strings.TrimSpace(parts[1])
// 去除行尾注释
if idx := strings.Index(v, "#"); idx >= 0 {
v = strings.TrimSpace(v[:idx])
// ExtensionList 获取扩展列表及安装状态
func (s *App) ExtensionList(w http.ResponseWriter, r *http.Request) {
extensions := s.getExtensions()
for i := range extensions {
controlPath := fmt.Sprintf("%s/server/postgresql/share/extension/%s.control", app.Root, extensions[i].ExtName)
extensions[i].Installed = io.Exists(controlPath)
if extensions[i].Installed {
control, _ := io.Read(controlPath)
if m := defaultVersionRegexp.FindStringSubmatch(control); len(m) > 1 {
extensions[i].InstalledVersion = m[1]
}
// 去除引号
v = strings.Trim(v, "'\"")
return v
}
}
return ""
service.Success(w, extensions)
}
// setPGValue 在 PostgreSQL 配置内容中设置指定键的值
func (s *App) setPGValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
// InstallExtension 安装扩展(异步任务)
func (s *App) InstallExtension(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ExtensionSlug](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
lines := strings.Split(content, "\n")
found := false
result := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
result = append(result, line)
continue
}
checkLine := trimmed
if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
}
parts := strings.SplitN(checkLine, "=", 2)
if len(parts) != 2 {
result = append(result, line)
continue
}
k := strings.TrimSpace(parts[0])
if k == key {
if found {
continue
}
found = true
// 值为空时注释掉该配置项
if value == "" {
if !strings.HasPrefix(trimmed, "#") {
result = append(result, "#"+line)
} else {
result = append(result, line)
}
continue
}
result = append(result, key+" = "+value)
} else {
result = append(result, line)
}
if !s.checkExtension(req.Slug) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("extension %s does not exist", req.Slug))
return
}
if !found && value != "" {
result = append(result, key+" = "+value)
cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://%s/postgresql/extensions/%s.sh' | bash -s -- 'install'`, s.conf.App.DownloadEndpoint, url.PathEscape(req.Slug))
task := new(biz.Task)
task.Key = "postgresql:extension:" + req.Slug
task.Name = s.t.Get("Install PostgreSQL extension %s", req.Slug)
task.Status = biz.TaskStatusWaiting
task.Shell = cmd
if err = s.taskRepo.Push(task); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
return strings.Join(result, "\n")
service.Success(w, nil)
}
// UninstallExtension 卸载扩展(异步任务)
func (s *App) UninstallExtension(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ExtensionSlug](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if !s.checkExtension(req.Slug) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("extension %s does not exist", req.Slug))
return
}
cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://%s/postgresql/extensions/%s.sh' | bash -s -- 'uninstall'`, s.conf.App.DownloadEndpoint, url.PathEscape(req.Slug))
task := new(biz.Task)
task.Key = "postgresql:extension:" + req.Slug
task.Name = s.t.Get("Uninstall PostgreSQL extension %s", req.Slug)
task.Status = biz.TaskStatusWaiting
task.Shell = cmd
if err = s.taskRepo.Push(task); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// EnableExtension 在指定数据库启用扩展
func (s *App) EnableExtension(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ExtensionEnable](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
ext, ok := lo.Find(s.getExtensions(), func(e Extension) bool {
return e.Slug == req.Slug
})
if !ok {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("extension %s does not exist", req.Slug))
return
}
postgres, err := s.connect(r.Context(), req.Database)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
if _, err = postgres.Exec(fmt.Sprintf(`CREATE EXTENSION IF NOT EXISTS "%s"`, ext.ExtName)); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to enable extension: %v", err))
return
}
service.Success(w, nil)
}
// SessionList 获取会话列表
func (s *App) SessionList(w http.ResponseWriter, r *http.Request) {
postgres, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
rows, err := postgres.Query(`
SELECT a.pid, coalesce(a.datname,''), coalesce(a.usename,''), coalesce(a.client_addr::text,''),
coalesce(a.state,''), coalesce(a.wait_event_type,''), coalesce(a.wait_event,''),
coalesce(array_to_string(pg_blocking_pids(a.pid),','),''),
coalesce(extract(epoch from (now()-a.xact_start))::bigint,0),
coalesce(extract(epoch from (now()-a.query_start))::bigint,0),
coalesce(a.query,'')
FROM pg_stat_activity a
WHERE a.pid != pg_backend_pid() AND a.backend_type = 'client backend'
ORDER BY a.backend_start`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
sessions := make([]Session, 0)
for rows.Next() {
var item Session
if err = rows.Scan(&item.PID, &item.Database, &item.User, &item.ClientAddr, &item.State,
&item.WaitEventType, &item.WaitEvent, &item.BlockedBy, &item.XactSeconds, &item.QuerySeconds, &item.Query); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
sessions = append(sessions, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, sessions)
}
// TerminateSession 终止会话
func (s *App) TerminateSession(w http.ResponseWriter, r *http.Request) {
pid := cast.ToInt64(chi.URLParam(r, "pid"))
if pid <= 0 {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid pid"))
return
}
postgres, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
if _, err = postgres.Exec(`SELECT pg_terminate_backend($1)`, pid); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// TopSQL 获取 SQL 性能统计
func (s *App) TopSQL(w http.ResponseWriter, r *http.Request) {
postgres, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
rows, err := postgres.Query(`
SELECT coalesce(d.datname,''), s.calls, round(s.total_exec_time)::bigint,
round(s.mean_exec_time::numeric,2)::float8, s.rows,
coalesce(round(100.0*s.shared_blks_hit/nullif(s.shared_blks_hit+s.shared_blks_read,0),1),0)::float8,
s.query
FROM pg_stat_statements s LEFT JOIN pg_database d ON d.oid = s.dbid
ORDER BY s.total_exec_time DESC LIMIT 50`)
if err != nil {
// pg_stat_statements 未启用时返回状态而非报错
if strings.Contains(err.Error(), "shared_preload_libraries") {
service.Success(w, TopSQL{Enabled: false, PendingRestart: true, Items: []TopSQLItem{}})
return
}
if strings.Contains(err.Error(), "does not exist") {
service.Success(w, TopSQL{Enabled: false, Items: []TopSQLItem{}})
return
}
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
items := make([]TopSQLItem, 0)
for rows.Next() {
var item TopSQLItem
if err = rows.Scan(&item.Database, &item.Calls, &item.TotalMs, &item.MeanMs, &item.Rows, &item.HitRate, &item.Query); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, TopSQL{Enabled: true, Items: items})
}
// EnableTopSQL 启用 pg_stat_statements
func (s *App) EnableTopSQL(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(s.configPath())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 将 pg_stat_statements 加入 shared_preload_libraries,重启后生效
var libs []string
for lib := range strings.SplitSeq(confval.Postgres.Get(config, "shared_preload_libraries"), ",") {
if lib = strings.TrimSpace(lib); lib != "" {
libs = append(libs, lib)
}
}
if !slices.Contains(libs, "pg_stat_statements") {
libs = append(libs, "pg_stat_statements")
config = confval.Postgres.Set(config, "shared_preload_libraries", strings.Join(libs, ","))
if err = io.Write(s.configPath(), config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
postgres, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
if _, err = postgres.Exec(`CREATE EXTENSION IF NOT EXISTS pg_stat_statements`); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// ResetTopSQL 重置 SQL 性能统计
func (s *App) ResetTopSQL(w http.ResponseWriter, r *http.Request) {
postgres, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
if _, err = postgres.Exec(`SELECT pg_stat_statements_reset()`); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// DatabaseList 获取可连接的数据库列表
func (s *App) DatabaseList(w http.ResponseWriter, r *http.Request) {
postgres, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
rows, err := postgres.Query(`SELECT datname FROM pg_database WHERE datallowconn ORDER BY datname`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
databases := make([]string, 0)
for rows.Next() {
var name string
if err = rows.Scan(&name); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
databases = append(databases, name)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, databases)
}
// BloatList 获取指定数据库的表膨胀情况
func (s *App) BloatList(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[BloatQuery](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
postgres, err := s.connect(r.Context(), req.Database)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
rows, err := postgres.Query(`
SELECT schemaname, relname, pg_size_pretty(pg_total_relation_size(relid)),
pg_total_relation_size(relid), n_live_tup, n_dead_tup,
coalesce(round(100.0*n_dead_tup/nullif(n_live_tup+n_dead_tup,0),1),0)::float8,
coalesce(to_char(last_vacuum,'YYYY-MM-DD HH24:MI'),''), coalesce(to_char(last_autovacuum,'YYYY-MM-DD HH24:MI'),''),
coalesce(to_char(last_analyze,'YYYY-MM-DD HH24:MI'),''), coalesce(to_char(last_autoanalyze,'YYYY-MM-DD HH24:MI'),'')
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 50`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = rows.Close() }()
items := make([]BloatItem, 0)
for rows.Next() {
var item BloatItem
if err = rows.Scan(&item.Schema, &item.Table, &item.Size, &item.SizeBytes, &item.LiveTuples, &item.DeadTuples,
&item.DeadRate, &item.LastVacuum, &item.LastAutovacuum, &item.LastAnalyze, &item.LastAutoanalyz); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, Bloat{
RepackInstalled: io.Exists(app.Root + "/server/postgresql/share/extension/pg_repack.control"),
Items: items,
})
}
// RunMaintenance 对表执行维护操作(异步任务)
func (s *App) RunMaintenance(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[MaintenanceRun](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if len(req.Tables) == 0 {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("no tables selected"))
return
}
if !slices.Contains([]string{"vacuum", "vacuum_full", "analyze", "repack"}, req.Operation) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid operation"))
return
}
var cmd string
if req.Operation == "repack" {
if !io.Exists(app.Root + "/server/postgresql/share/extension/pg_repack.control") {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("pg_repack is not installed, please install it in the extensions tab first"))
return
}
var args strings.Builder
for _, table := range req.Tables {
fmt.Fprintf(&args, ` -t "%s.%s"`, table.Schema, table.Table)
}
cmd = fmt.Sprintf(`su - postgres -c 'pg_repack -d "%s"%s'`, req.Database, args.String())
} else {
statement := map[string]string{"vacuum": "VACUUM", "vacuum_full": "VACUUM FULL", "analyze": "ANALYZE"}[req.Operation]
var args strings.Builder
for _, table := range req.Tables {
fmt.Fprintf(&args, ` -c "%s \"%s\".\"%s\""`, statement, table.Schema, table.Table)
}
cmd = fmt.Sprintf(`su - postgres -c 'psql -d "%s"%s'`, req.Database, args.String())
}
task := new(biz.Task)
task.Key = "postgresql:maintenance:" + req.Database
task.Name = s.t.Get("Run %[1]s on %[2]d tables in database %[3]s", req.Operation, len(req.Tables), req.Database)
task.Status = biz.TaskStatusWaiting
task.Shell = cmd
if err = s.taskRepo.Push(task); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// WalStatus 获取 WAL 及复制状态
func (s *App) WalStatus(w http.ResponseWriter, r *http.Request) {
postgres, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
var wal Wal
if err = postgres.QueryRow(`SELECT pg_size_pretty(coalesce(sum(size),0)) FROM pg_ls_waldir()`).Scan(&wal.WalSize); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = postgres.QueryRow(`SELECT archived_count, failed_count, coalesce(last_archived_wal,''), coalesce(last_failed_wal,'') FROM pg_stat_archiver`).Scan(
&wal.Archiver.ArchivedCount, &wal.Archiver.FailedCount, &wal.Archiver.LastArchivedWal, &wal.Archiver.LastFailedWal); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
slotRows, err := postgres.Query(`SELECT slot_name, slot_type, active, coalesce(pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)),'') FROM pg_replication_slots`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = slotRows.Close() }()
wal.Slots = make([]ReplicationSlot, 0)
for slotRows.Next() {
var slot ReplicationSlot
if err = slotRows.Scan(&slot.Name, &slot.Type, &slot.Active, &slot.RetainedWal); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
wal.Slots = append(wal.Slots, slot)
}
if err = slotRows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
replRows, err := postgres.Query(`SELECT coalesce(client_addr::text,''), coalesce(state,''), coalesce(sync_state,''), coalesce(pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)),'') FROM pg_stat_replication`)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer func() { _ = replRows.Close() }()
wal.Replications = make([]Replication, 0)
for replRows.Next() {
var repl Replication
if err = replRows.Scan(&repl.ClientAddr, &repl.State, &repl.SyncState, &repl.Lag); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
wal.Replications = append(wal.Replications, repl)
}
if err = replRows.Err(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, wal)
}
// DropReplicationSlot 删除复制槽
func (s *App) DropReplicationSlot(w http.ResponseWriter, r *http.Request) {
slot := chi.URLParam(r, "slot")
if slot == "" {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid slot name"))
return
}
postgres, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer postgres.Close()
if _, err = postgres.Exec(`SELECT pg_drop_replication_slot($1)`, slot); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// getExtensions 返回所有扩展定义
func (s *App) getExtensions() []Extension {
return []Extension{
{Name: "pgvector", Slug: "pgvector", ExtName: "vector", Description: s.t.Get("Vector similarity search")},
{Name: "PostGIS", Slug: "postgis", ExtName: "postgis", Description: s.t.Get("Spatial and geographic objects support")},
{Name: "TimescaleDB", Slug: "timescaledb", ExtName: "timescaledb", Description: s.t.Get("Time-series database engine, requires restarting PostgreSQL after installation")},
{Name: "zhparser", Slug: "zhparser", ExtName: "zhparser", Description: s.t.Get("Chinese full-text search parser based on SCWS")},
{Name: "pg_repack", Slug: "pg_repack", ExtName: "pg_repack", Description: s.t.Get("Reorganize tables online to remove bloat")},
{Name: "pg_cron", Slug: "pg_cron", ExtName: "pg_cron", Description: s.t.Get("Run periodic jobs inside the database, requires restarting PostgreSQL after installation")},
{Name: "pg_partman", Slug: "pg_partman", ExtName: "pg_partman", Description: s.t.Get("Automated partition management")},
{Name: "pgaudit", Slug: "pgaudit", ExtName: "pgaudit", Description: s.t.Get("Session and object audit logging, requires restarting PostgreSQL after installation")},
{Name: "pg_hint_plan", Slug: "pg_hint_plan", ExtName: "pg_hint_plan", Description: s.t.Get("Control execution plans with hints in SQL comments, requires restarting PostgreSQL after installation")},
{Name: "pg_stat_monitor", Slug: "pg_stat_monitor", ExtName: "pg_stat_monitor", Description: s.t.Get("Advanced query performance monitoring, requires restarting PostgreSQL after installation")},
{Name: "pg_ivm", Slug: "pg_ivm", ExtName: "pg_ivm", Description: s.t.Get("Incremental view maintenance for materialized views")},
{Name: "hypopg", Slug: "hypopg", ExtName: "hypopg", Description: s.t.Get("Hypothetical indexes for query plan testing")},
{Name: "pgmq", Slug: "pgmq", ExtName: "pgmq", Description: s.t.Get("Lightweight message queue")},
{Name: "orafce", Slug: "orafce", ExtName: "orafce", Description: s.t.Get("Oracle compatibility functions")},
{Name: "http", Slug: "http", ExtName: "http", Description: s.t.Get("HTTP client for SQL, send requests from the database")},
}
}
// checkExtension 检查 slug 是否有效
func (s *App) checkExtension(slug string) bool {
return lo.ContainsBy(s.getExtensions(), func(e Extension) bool {
return e.Slug == slug
})
}
// connect 以 postgres 超级用户连接指定数据库,默认 postgres 库
func (s *App) connect(ctx context.Context, database ...string) (db.Operator, error) {
password, err := s.settingRepo.Get(biz.SettingKeyPostgresPassword)
if err != nil {
return nil, err
}
return db.NewPostgres(ctx, "postgres", password, "127.0.0.1", db.PostgresPort(app.Root), database...)
}
func (s *App) configPath() string {
return app.Root + "/server/postgresql/data/postgresql.conf"
}
// parsePort 从 config 内容解析端口,未配置时返回默认值
func (s *App) parsePort(config string) uint {
port := cast.ToUint(confval.Postgres.Get(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)
}
+127 -2
View File
@@ -5,14 +5,139 @@ type UpdateConfig struct {
}
type SetPostgresPassword struct {
Password string `form:"password" json:"password" validate:"required|password"`
Password string `form:"password" json:"password" validate:"required && password"`
}
// Extension PostgreSQL 扩展信息
type Extension struct {
Name string `json:"name"`
Slug string `json:"slug"` // 下载脚本名,如 pgvector
ExtName string `json:"ext_name"` // CREATE EXTENSION 使用的扩展名,如 vector
Description string `json:"description"`
Installed bool `json:"installed"`
InstalledVersion string `json:"installed_version"` // control 文件中的 default_version,仅展示
}
// ExtensionSlug 扩展操作请求
type ExtensionSlug struct {
Slug string `form:"slug" json:"slug" validate:"required"`
}
// ExtensionEnable 在指定数据库启用扩展请求
type ExtensionEnable struct {
Slug string `form:"slug" json:"slug" validate:"required"`
Database string `form:"database" json:"database" validate:"required"`
}
// Session 数据库会话信息
type Session struct {
PID int64 `json:"pid"`
Database string `json:"database"`
User string `json:"user"`
ClientAddr string `json:"client_addr"`
State string `json:"state"`
WaitEventType string `json:"wait_event_type"`
WaitEvent string `json:"wait_event"`
BlockedBy string `json:"blocked_by"`
XactSeconds int64 `json:"xact_seconds"`
QuerySeconds int64 `json:"query_seconds"`
Query string `json:"query"`
}
// TopSQLItem Top SQL 统计项
type TopSQLItem struct {
Database string `json:"database"`
Calls int64 `json:"calls"`
TotalMs int64 `json:"total_ms"`
MeanMs float64 `json:"mean_ms"`
Rows int64 `json:"rows"`
HitRate float64 `json:"hit_rate"`
Query string `json:"query"`
}
// TopSQL Top SQL 响应
type TopSQL struct {
Enabled bool `json:"enabled"`
PendingRestart bool `json:"pending_restart"` // 已配置 preload,等待重启生效
Items []TopSQLItem `json:"items"`
}
// BloatQuery 表膨胀查询请求
type BloatQuery struct {
Database string `form:"database" json:"database" validate:"required"`
}
// BloatItem 表膨胀信息
type BloatItem struct {
Schema string `json:"schema"`
Table string `json:"table"`
Size string `json:"size"`
SizeBytes int64 `json:"size_bytes"`
LiveTuples int64 `json:"live_tuples"`
DeadTuples int64 `json:"dead_tuples"`
DeadRate float64 `json:"dead_rate"`
LastVacuum string `json:"last_vacuum"`
LastAutovacuum string `json:"last_autovacuum"`
LastAnalyze string `json:"last_analyze"`
LastAutoanalyz string `json:"last_autoanalyze"`
}
// Bloat 表膨胀响应
type Bloat struct {
RepackInstalled bool `json:"repack_installed"`
Items []BloatItem `json:"items"`
}
// MaintenanceTable 维护操作的目标表
type MaintenanceTable struct {
Schema string `form:"schema" json:"schema" validate:"required"`
Table string `form:"table" json:"table" validate:"required"`
}
// MaintenanceRun 表维护操作请求
type MaintenanceRun struct {
Database string `form:"database" json:"database" validate:"required"`
Tables []MaintenanceTable `form:"tables" json:"tables"`
Operation string `form:"operation" json:"operation" validate:"required"`
}
// ReplicationSlot 复制槽信息
type ReplicationSlot struct {
Name string `json:"name"`
Type string `json:"type"`
Active bool `json:"active"`
RetainedWal string `json:"retained_wal"`
}
// Replication 流复制连接信息
type Replication struct {
ClientAddr string `json:"client_addr"`
State string `json:"state"`
SyncState string `json:"sync_state"`
Lag string `json:"lag"`
}
// WalArchiver WAL 归档统计
type WalArchiver struct {
ArchivedCount int64 `json:"archived_count"`
FailedCount int64 `json:"failed_count"`
LastArchivedWal string `json:"last_archived_wal"`
LastFailedWal string `json:"last_failed_wal"`
}
// Wal WAL 状态响应
type Wal struct {
WalSize string `json:"wal_size"`
Archiver WalArchiver `json:"archiver"`
Slots []ReplicationSlot `json:"slots"`
Replications []Replication `json:"replications"`
}
// 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"`
// 内存设置
+420
View File
@@ -0,0 +1,420 @@
package prometheus
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/spf13/cast"
"go.yaml.in/yaml/v4"
"resty.dev/v3"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"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/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
conf *config.Config
taskRepo biz.TaskRepo
}
func NewApp(conf *config.Config, t *gotext.Locale, taskRepo biz.TaskRepo) *App {
return &App{t: t, conf: conf, taskRepo: taskRepo}
}
func (s *App) Route(r chi.Router) {
r.Get("/load", s.Load)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
// Alertmanager 配置
r.Get("/alertmanager_config", s.GetAlertmanagerConfig)
r.Post("/alertmanager_config", s.UpdateAlertmanagerConfig)
// Exporters 管理
r.Get("/exporters", s.ExporterList)
r.Post("/exporters", s.InstallExporter)
r.Delete("/exporters", s.UninstallExporter)
r.Post("/exporters/{slug}/start", s.StartExporter)
r.Post("/exporters/{slug}/stop", s.StopExporter)
r.Post("/exporters/{slug}/restart", s.RestartExporter)
r.Get("/exporters/{slug}/config", s.GetExporterConfig)
r.Post("/exporters/{slug}/config", s.UpdateExporterConfig)
}
func (s *App) Status() string {
prom, _ := systemctl.Status("prometheus")
alert, _ := systemctl.Status("alertmanager")
return types.AggregateAppStatus(prom, alert)
}
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, err := systemctl.Status("prometheus")
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get prometheus status: %v", err))
return
}
if !status {
service.Success(w, []types.NV{})
return
}
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.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
var result struct {
Data struct {
StartTime string `json:"startTime"`
GoroutineCount int `json:"goroutineCount"`
GOMAXPROCS int `json:"GOMAXPROCS"`
StorageRetention string `json:"storageRetention"`
ReloadConfigSuccess bool `json:"reloadConfigSuccess"`
} `json:"data"`
}
if err = json.Unmarshal(resp.Bytes(), &result); err != nil {
service.Success(w, []types.NV{})
return
}
data := []types.NV{
{Name: s.t.Get("Start Time"), Value: result.Data.StartTime},
{Name: s.t.Get("Storage Retention"), Value: result.Data.StorageRetention},
{Name: s.t.Get("Goroutine Count"), Value: cast.ToString(result.Data.GoroutineCount)},
{Name: "GOMAXPROCS", Value: cast.ToString(result.Data.GOMAXPROCS)},
{Name: s.t.Get("Config Reload Success"), Value: cast.ToString(result.Data.ReloadConfigSuccess)},
}
service.Success(w, data)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
common.ServeConfig(w, app.Root+"/server/prometheus/prometheus.yml")
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
common.SaveConfig(w, r, app.Root+"/server/prometheus/prometheus.yml", "prometheus")
}
// GetConfigTune 获取 Prometheus 全局配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
conf, _ := io.Read(app.Root + "/server/prometheus/prometheus.yml")
var cfg struct {
Global struct {
ScrapeInterval string `yaml:"scrape_interval"`
EvaluationInterval string `yaml:"evaluation_interval"`
ScrapeTimeout string `yaml:"scrape_timeout"`
} `yaml:"global"`
}
_ = yaml.Unmarshal([]byte(conf), &cfg)
tune := ConfigTune{
ScrapeInterval: cfg.Global.ScrapeInterval,
EvaluationInterval: cfg.Global.EvaluationInterval,
ScrapeTimeout: cfg.Global.ScrapeTimeout,
}
service.Success(w, tune)
}
// UpdateConfigTune 更新 Prometheus 全局配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
confPath := app.Root + "/server/prometheus/prometheus.yml"
raw, _ := io.Read(confPath)
var cfg map[string]any
if err = yaml.Unmarshal([]byte(raw), &cfg); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
global, ok := cfg["global"].(map[string]any)
if !ok {
global = make(map[string]any)
}
if req.ScrapeInterval != "" {
global["scrape_interval"] = req.ScrapeInterval
}
if req.EvaluationInterval != "" {
global["evaluation_interval"] = req.EvaluationInterval
}
if req.ScrapeTimeout != "" {
global["scrape_timeout"] = req.ScrapeTimeout
}
cfg["global"] = global
data, err := yaml.Marshal(cfg)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(confPath, string(data), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("prometheus"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// GetAlertmanagerConfig 获取 Alertmanager 配置
func (s *App) GetAlertmanagerConfig(w http.ResponseWriter, r *http.Request) {
conf, _ := io.Read(app.Root + "/server/prometheus/alertmanager/alertmanager.yml")
service.Success(w, conf)
}
// UpdateAlertmanagerConfig 更新 Alertmanager 配置
func (s *App) UpdateAlertmanagerConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(app.Root+"/server/prometheus/alertmanager/alertmanager.yml", req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("alertmanager"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// ExporterList 获取 Exporter 列表及状态
func (s *App) ExporterList(w http.ResponseWriter, r *http.Request) {
exporters := s.getExporters()
for i := range exporters {
exporters[i].Installed = io.Exists(fmt.Sprintf("%s/server/prometheus/exporters/%s", app.Root, exporters[i].Slug))
if exporters[i].Installed {
running, _ := systemctl.Status("prometheus-" + exporters[i].Slug)
exporters[i].Running = running
}
}
service.Success(w, exporters)
}
// InstallExporter 安装 Exporter(异步任务)
func (s *App) InstallExporter(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ExporterSlug](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if !s.checkExporter(req.Slug) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("exporter %s does not exist", req.Slug))
return
}
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
if err = s.taskRepo.Push(task); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// UninstallExporter 卸载 Exporter(异步任务)
func (s *App) UninstallExporter(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ExporterSlug](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if !s.checkExporter(req.Slug) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("exporter %s does not exist", req.Slug))
return
}
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
if err = s.taskRepo.Push(task); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// StartExporter 启动 Exporter
func (s *App) StartExporter(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if !s.checkExporter(slug) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("exporter %s does not exist", slug))
return
}
if err := systemctl.Start("prometheus-" + slug); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// StopExporter 停止 Exporter
func (s *App) StopExporter(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if !s.checkExporter(slug) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("exporter %s does not exist", slug))
return
}
if err := systemctl.Stop("prometheus-" + slug); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// RestartExporter 重启 Exporter
func (s *App) RestartExporter(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if !s.checkExporter(slug) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("exporter %s does not exist", slug))
return
}
if err := systemctl.Restart("prometheus-" + slug); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// GetExporterConfig 获取 Exporter 配置
func (s *App) GetExporterConfig(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if !s.checkExporter(slug) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("exporter %s does not exist", slug))
return
}
confPath := s.getExporterConfigPath(slug)
if confPath == "" {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("exporter %s has no configuration file", slug))
return
}
conf, _ := io.Read(confPath)
service.Success(w, conf)
}
// UpdateExporterConfig 更新 Exporter 配置
func (s *App) UpdateExporterConfig(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if !s.checkExporter(slug) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("exporter %s does not exist", slug))
return
}
confPath := s.getExporterConfigPath(slug)
if confPath == "" {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("exporter %s has no configuration file", slug))
return
}
req, err := service.Bind[ExporterConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(confPath, req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("prometheus-" + slug); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// getExporters 返回所有 exporter 定义
func (s *App) getExporters() []Exporter {
return []Exporter{
{Name: "Node Exporter", Slug: "node_exporter", Description: s.t.Get("Hardware and OS metrics")},
{Name: "Nginx Exporter", Slug: "nginx_exporter", Description: s.t.Get("Nginx metrics")},
{Name: "MySQL Exporter", Slug: "mysqld_exporter", Description: s.t.Get("MySQL database metrics"), HasConfig: true},
{Name: "PostgreSQL Exporter", Slug: "postgres_exporter", Description: s.t.Get("PostgreSQL database metrics"), HasConfig: true},
{Name: "MongoDB Exporter", Slug: "mongodb_exporter", Description: s.t.Get("MongoDB metrics"), HasConfig: true},
{Name: "Elasticsearch Exporter", Slug: "elasticsearch_exporter", Description: s.t.Get("Elasticsearch metrics"), HasConfig: true},
{Name: "Redis Exporter", Slug: "redis_exporter", Description: s.t.Get("Redis metrics"), HasConfig: true},
{Name: "Memcached Exporter", Slug: "memcached_exporter", Description: s.t.Get("Memcached metrics")},
{Name: "Kafka Exporter", Slug: "kafka_exporter", Description: s.t.Get("Kafka metrics"), HasConfig: true},
}
}
// getExporterConfigPath 获取 exporter 配置文件路径
func (s *App) getExporterConfigPath(slug string) string {
base := fmt.Sprintf("%s/server/prometheus/exporters/%s", app.Root, slug)
switch slug {
case "redis_exporter", "postgres_exporter", "elasticsearch_exporter", "mongodb_exporter", "kafka_exporter":
return base + "/env"
case "mysqld_exporter":
return base + "/.my.cnf"
default:
return ""
}
}
// checkExporter 检查 slug 是否有效
func (s *App) checkExporter(slug string) bool {
return lo.ContainsBy(s.getExporters(), func(e Exporter) bool {
return e.Slug == slug
})
}
+32
View File
@@ -0,0 +1,32 @@
package prometheus
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// ConfigTune Prometheus 全局配置调整
type ConfigTune struct {
ScrapeInterval string `form:"scrape_interval" json:"scrape_interval"`
EvaluationInterval string `form:"evaluation_interval" json:"evaluation_interval"`
ScrapeTimeout string `form:"scrape_timeout" json:"scrape_timeout"`
}
// Exporter Prometheus Exporter 信息
type Exporter struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Installed bool `json:"installed"`
Running bool `json:"running"`
HasConfig bool `json:"has_config"`
}
// ExporterSlug Exporter 操作请求
type ExporterSlug struct {
Slug string `form:"slug" json:"slug" validate:"required"`
}
// ExporterConfig Exporter 配置更新请求
type ExporterConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
+46 -101
View File
@@ -8,15 +8,18 @@ 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"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/firewall"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/service"
"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 {
@@ -24,6 +27,7 @@ type App struct {
}
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}
@@ -40,6 +44,11 @@ func (s *App) Route(r chi.Router) {
r.Post("/config_tune", s.UpdateConfigTune)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("pure-ftpd")
return types.AggregateAppStatus(ok)
}
// List 获取用户列表
func (s *App) List(w http.ResponseWriter, r *http.Request) {
listRaw, err := shell.Execf("pure-pw list")
@@ -50,19 +59,17 @@ func (s *App) List(w http.ResponseWriter, r *http.Request) {
})
}
listArr := strings.Split(listRaw, "\n")
var users []User
for _, v := range listArr {
userRe := regexp.MustCompile(`(\S+)\s+(\S+)`)
users := lo.FilterMap(strings.Split(listRaw, "\n"), func(v string, _ int) (User, bool) {
if len(v) == 0 {
continue
return User{}, false
}
match := regexp.MustCompile(`(\S+)\s+(\S+)`).FindStringSubmatch(v)
users = append(users, User{
match := userRe.FindStringSubmatch(v)
return User{
Username: match[1],
Path: strings.Replace(match[2], "/./", "/", 1),
})
}
}, true
})
paged, total := service.Paginate(r, users)
@@ -142,13 +149,13 @@ func (s *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
// GetPort 获取端口
func (s *App) GetPort(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/pure-ftpd/etc/pure-ftpd.conf", app.Root))
config, err := io.Read(app.Root + "/server/pure-ftpd/etc/pure-ftpd.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get port: %v", err))
return
}
bind := strings.Trim(s.getFTPValue(config, "Bind"), `"'`)
bind := strings.Trim(confval.FTP.Get(config, "Bind"), `"'`)
port := 21 // 默认端口
if parts := strings.SplitN(bind, ",", 2); len(parts) == 2 {
port = cast.ToInt(strings.TrimSpace(parts[1]))
@@ -165,13 +172,13 @@ func (s *App) UpdatePort(w http.ResponseWriter, r *http.Request) {
return
}
confPath := fmt.Sprintf("%s/server/pure-ftpd/etc/pure-ftpd.conf", app.Root)
confPath := app.Root + "/server/pure-ftpd/etc/pure-ftpd.conf"
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
config = s.setFTPValue(config, "Bind", fmt.Sprintf(`"0.0.0.0,%d"`, req.Port))
config = confval.FTP.Set(config, "Bind", fmt.Sprintf(`"0.0.0.0,%d"`, req.Port))
if err = io.Write(confPath, config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -182,8 +189,8 @@ func (s *App) UpdatePort(w http.ResponseWriter, r *http.Request) {
Type: firewall.TypeNormal,
PortStart: req.Port,
PortEnd: req.Port,
Direction: firewall.DirectionIn,
Strategy: firewall.StrategyAccept,
Direction: firewall.DirectionIn,
}, firewall.OperationAdd)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -200,21 +207,21 @@ func (s *App) UpdatePort(w http.ResponseWriter, r *http.Request) {
// GetConfigTune 获取 Pure-FTPd 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/pure-ftpd/etc/pure-ftpd.conf", app.Root))
config, err := io.Read(app.Root + "/server/pure-ftpd/etc/pure-ftpd.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
tune := ConfigTune{
MaxClientsNumber: s.getFTPValue(config, "MaxClientsNumber"),
MaxClientsPerIP: s.getFTPValue(config, "MaxClientsPerIP"),
MaxIdleTime: s.getFTPValue(config, "MaxIdleTime"),
MaxLoad: s.getFTPValue(config, "MaxLoad"),
PassivePortRange: s.getFTPValue(config, "PassivePortRange"),
AnonymousOnly: s.getFTPValue(config, "AnonymousOnly"),
NoAnonymous: s.getFTPValue(config, "NoAnonymous"),
MaxDiskUsage: s.getFTPValue(config, "MaxDiskUsage"),
MaxClientsNumber: confval.FTP.Get(config, "MaxClientsNumber"),
MaxClientsPerIP: confval.FTP.Get(config, "MaxClientsPerIP"),
MaxIdleTime: confval.FTP.Get(config, "MaxIdleTime"),
MaxLoad: confval.FTP.Get(config, "MaxLoad"),
PassivePortRange: confval.FTP.Get(config, "PassivePortRange"),
AnonymousOnly: confval.FTP.Get(config, "AnonymousOnly"),
NoAnonymous: confval.FTP.Get(config, "NoAnonymous"),
MaxDiskUsage: confval.FTP.Get(config, "MaxDiskUsage"),
}
service.Success(w, tune)
@@ -228,21 +235,21 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
return
}
confPath := fmt.Sprintf("%s/server/pure-ftpd/etc/pure-ftpd.conf", app.Root)
confPath := app.Root + "/server/pure-ftpd/etc/pure-ftpd.conf"
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
config = s.setFTPValue(config, "MaxClientsNumber", req.MaxClientsNumber)
config = s.setFTPValue(config, "MaxClientsPerIP", req.MaxClientsPerIP)
config = s.setFTPValue(config, "MaxIdleTime", req.MaxIdleTime)
config = s.setFTPValue(config, "MaxLoad", req.MaxLoad)
config = s.setFTPValue(config, "PassivePortRange", req.PassivePortRange)
config = s.setFTPValue(config, "AnonymousOnly", req.AnonymousOnly)
config = s.setFTPValue(config, "NoAnonymous", req.NoAnonymous)
config = s.setFTPValue(config, "MaxDiskUsage", req.MaxDiskUsage)
config = confval.FTP.Set(config, "MaxClientsNumber", req.MaxClientsNumber)
config = confval.FTP.Set(config, "MaxClientsPerIP", req.MaxClientsPerIP)
config = confval.FTP.Set(config, "MaxIdleTime", req.MaxIdleTime)
config = confval.FTP.Set(config, "MaxLoad", req.MaxLoad)
config = confval.FTP.Set(config, "PassivePortRange", req.PassivePortRange)
config = confval.FTP.Set(config, "AnonymousOnly", req.AnonymousOnly)
config = confval.FTP.Set(config, "NoAnonymous", req.NoAnonymous)
config = confval.FTP.Set(config, "MaxDiskUsage", req.MaxDiskUsage)
if err = io.Write(confPath, config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -256,65 +263,3 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
// getFTPValue 从 Pure-FTPd 配置内容中获取指定键的值
func (s *App) getFTPValue(content string, key string) string {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
parts := strings.Fields(trimmed)
if len(parts) >= 2 && parts[0] == key {
return strings.Join(parts[1:], " ")
}
}
return ""
}
// setFTPValue 在 Pure-FTPd 配置内容中设置指定键的值
func (s *App) setFTPValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
lines := strings.Split(content, "\n")
found := false
result := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
result = append(result, line)
continue
}
checkLine := trimmed
if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
}
parts := strings.Fields(checkLine)
if len(parts) >= 2 && parts[0] == key {
if found {
continue
}
found = true
// 值为空时注释掉该配置项
if value == "" {
if !strings.HasPrefix(trimmed, "#") {
result = append(result, "#"+line)
} else {
result = append(result, line)
}
continue
}
// 保留原行格式
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
result = append(result, indent+key+" "+value)
} else {
result = append(result, line)
}
}
if !found && value != "" {
result = append(result, key+" "+value)
}
return strings.Join(result, "\n")
}
+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"`
}
+325 -117
View File
@@ -1,29 +1,52 @@
package redis
import (
"context"
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
redigo "github.com/gomodule/redigo/redis"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/spf13/cast"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/pkg/types"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/db"
"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/tools"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
t *gotext.Locale
databaseServerRepo biz.DatabaseServerRepo
taskRepo biz.TaskRepo
slug string // 服务名与配置目录名,如 redis、valkey
name string // 展示名,如 Redis、Valkey
}
func NewApp(t *gotext.Locale) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
return New("redis", "Redis", t, databaseServerRepo, taskRepo)
}
func New(slug, name string, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
return &App{
t: t,
t: t,
databaseServerRepo: databaseServerRepo,
taskRepo: taskRepo,
slug: slug,
name: name,
}
}
@@ -33,12 +56,24 @@ func (s *App) Route(r chi.Router) {
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
// 性能诊断
r.Get("/slow_log", s.SlowLog)
r.Post("/slow_log/reset", s.ResetSlowLog)
r.Get("/clients", s.ClientList)
r.Post("/clients/kill", s.KillClient)
r.Get("/memory", s.MemoryStatus)
r.Post("/bigkeys", s.ScanBigKeys)
}
func (s *App) Status() string {
ok, _ := systemctl.Status(s.slug)
return types.AggregateAppStatus(ok)
}
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, err := systemctl.Status("redis")
status, err := systemctl.Status(s.slug)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get redis status: %v", err))
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get %s status: %v", s.name, err))
return
}
if !status {
@@ -46,34 +81,32 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
return
}
// 检查 Redis 密码
// 检查密码
withPassword := ""
config, err := io.Read(fmt.Sprintf("%s/server/redis/redis.conf", app.Root))
config, err := io.Read(s.confPath())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
re := regexp.MustCompile(`^requirepass\s+(.+)`)
re := regexp.MustCompile(`(?m)^requirepass\s+(.+)`)
matches := re.FindStringSubmatch(config)
if len(matches) == 2 {
withPassword = " -a " + matches[1]
}
raw, err := shell.Execf("redis-cli%s info", withPassword)
raw, err := shell.Execf("%s%s info", s.slug+"-cli", withPassword)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get redis info: %v", err))
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get %s info: %v", s.name, err))
return
}
infoLines := strings.Split(raw, "\n")
dataRaw := make(map[string]string)
for _, item := range infoLines {
dataRaw := lo.SliceToMap(strings.Split(raw, "\n"), func(item string) (string, string) {
parts := strings.Split(item, ":")
if len(parts) == 2 {
dataRaw[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
if len(parts) != 2 {
return "", ""
}
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
})
data := []types.NV{
{Name: s.t.Get("TCP Port"), Value: dataRaw["tcp_port"]},
@@ -95,60 +128,38 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/redis/redis.conf", app.Root))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, config)
common.ServeConfig(w, s.confPath())
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(fmt.Sprintf("%s/server/redis/redis.conf", app.Root), req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("redis"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
common.SaveConfig(w, r, s.confPath(), s.slug)
}
// GetConfigTune 获取 Redis 配置调整参数
// GetConfigTune 获取配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/redis/redis.conf", app.Root))
config, err := io.Read(s.confPath())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
tune := ConfigTune{
Bind: s.getRedisValue(config, "bind"),
Port: s.getRedisValue(config, "port"),
Databases: s.getRedisValue(config, "databases"),
Requirepass: s.getRedisValue(config, "requirepass"),
Timeout: s.getRedisValue(config, "timeout"),
TCPKeepalive: s.getRedisValue(config, "tcp-keepalive"),
Maxmemory: s.getRedisValue(config, "maxmemory"),
MaxmemoryPolicy: s.getRedisValue(config, "maxmemory-policy"),
Appendonly: s.getRedisValue(config, "appendonly"),
Appendfsync: s.getRedisValue(config, "appendfsync"),
Bind: confval.Directive.Get(config, "bind"),
Port: confval.Directive.Get(config, "port"),
Databases: confval.Directive.Get(config, "databases"),
Requirepass: confval.Directive.Get(config, "requirepass"),
Timeout: confval.Directive.Get(config, "timeout"),
TCPKeepalive: confval.Directive.Get(config, "tcp-keepalive"),
Maxmemory: confval.Directive.Get(config, "maxmemory"),
MaxmemoryPolicy: confval.Directive.Get(config, "maxmemory-policy"),
Appendonly: confval.Directive.Get(config, "appendonly"),
Appendfsync: confval.Directive.Get(config, "appendfsync"),
}
service.Success(w, tune)
}
// UpdateConfigTune 更新 Redis 配置调整参数
// UpdateConfigTune 更新配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
@@ -156,30 +167,94 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
return
}
confPath := fmt.Sprintf("%s/server/redis/redis.conf", app.Root)
confPath := s.confPath()
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
config = s.setRedisValue(config, "bind", req.Bind)
config = s.setRedisValue(config, "port", req.Port)
config = s.setRedisValue(config, "databases", req.Databases)
config = s.setRedisValue(config, "requirepass", req.Requirepass)
config = s.setRedisValue(config, "timeout", req.Timeout)
config = s.setRedisValue(config, "tcp-keepalive", req.TCPKeepalive)
config = s.setRedisValue(config, "maxmemory", req.Maxmemory)
config = s.setRedisValue(config, "maxmemory-policy", req.MaxmemoryPolicy)
config = s.setRedisValue(config, "appendonly", req.Appendonly)
config = s.setRedisValue(config, "appendfsync", req.Appendfsync)
config = confval.Directive.Set(config, "bind", req.Bind)
config = confval.Directive.Set(config, "port", req.Port)
config = confval.Directive.Set(config, "databases", req.Databases)
config = confval.Directive.Set(config, "requirepass", req.Requirepass)
config = confval.Directive.Set(config, "timeout", req.Timeout)
config = confval.Directive.Set(config, "tcp-keepalive", req.TCPKeepalive)
config = confval.Directive.Set(config, "maxmemory", req.Maxmemory)
config = confval.Directive.Set(config, "maxmemory-policy", req.MaxmemoryPolicy)
config = confval.Directive.Set(config, "appendonly", req.Appendonly)
config = confval.Directive.Set(config, "appendfsync", req.Appendfsync)
if err = io.Write(confPath, config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("redis"); err != nil {
if err = systemctl.Restart(s.slug); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 同步密码到数据库服务器记录
_ = s.databaseServerRepo.UpdatePassword("local_"+s.slug, req.Requirepass)
service.Success(w, nil)
}
// SlowLog 获取慢日志
func (s *App) SlowLog(w http.ResponseWriter, r *http.Request) {
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
reply, err := conn.Exec("SLOWLOG", "GET", 50)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
rows, err := redigo.Values(reply, nil)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
entries := make([]SlowLogEntry, 0, len(rows))
for _, row := range rows {
item, itemErr := redigo.Values(row, nil)
if itemErr != nil || len(item) < 4 {
continue
}
entry := SlowLogEntry{}
entry.ID, _ = redigo.Int64(item[0], nil)
if ts, tsErr := redigo.Int64(item[1], nil); tsErr == nil {
entry.Time = time.Unix(ts, 0).Format(time.DateTime)
}
entry.DurationUs, _ = redigo.Int64(item[2], nil)
if cmd, cmdErr := redigo.Strings(item[3], nil); cmdErr == nil {
entry.Command = strings.Join(cmd, " ")
}
if len(item) > 4 {
entry.Client, _ = redigo.String(item[4], nil)
}
entries = append(entries, entry)
}
service.Success(w, entries)
}
// ResetSlowLog 重置慢日志
func (s *App) ResetSlowLog(w http.ResponseWriter, r *http.Request) {
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
if _, err = conn.Exec("SLOWLOG", "RESET"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -187,62 +262,195 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
// getRedisValue 从 Redis 配置内容中获取指定键的值
func (s *App) getRedisValue(content string, key string) string {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
// ClientList 获取客户端连接列表
func (s *App) ClientList(w http.ResponseWriter, r *http.Request) {
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
raw, err := redigo.String(conn.Exec("CLIENT", "LIST"))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
clients := make([]Client, 0)
for line := range strings.SplitSeq(strings.TrimSpace(raw), "\n") {
if line == "" {
continue
}
parts := strings.Fields(trimmed)
if len(parts) >= 2 && parts[0] == key {
return strings.Join(parts[1:], " ")
fields := map[string]string{}
for kv := range strings.FieldsSeq(line) {
if key, value, found := strings.Cut(kv, "="); found {
fields[key] = value
}
}
clients = append(clients, Client{
ID: fields["id"],
Addr: fields["addr"],
Name: fields["name"],
DB: fields["db"],
Age: fields["age"],
Idle: fields["idle"],
Cmd: fields["cmd"],
})
}
return ""
service.Success(w, clients)
}
// setRedisValue 在 Redis 配置内容中设置指定键的值
func (s *App) setRedisValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
// KillClient 踢除客户端连接
func (s *App) KillClient(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ClientKill](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
lines := strings.Split(content, "\n")
result := make([]string, 0, len(lines))
found := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
result = append(result, line)
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
killed, err := redigo.Int64(conn.Exec("CLIENT", "KILL", "ID", req.ID))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if killed == 0 {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("client %d not found", req.ID))
return
}
service.Success(w, nil)
}
// MemoryStatus 获取内存诊断信息
func (s *App) MemoryStatus(w http.ResponseWriter, r *http.Request) {
conn, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer conn.Close()
memory := Memory{Items: make([]types.NV, 0)}
memory.Doctor, err = redigo.String(conn.Exec("MEMORY", "DOCTOR"))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 空库与无问题时返回的是电影梗彩蛋文案,无展示价值
if strings.HasPrefix(memory.Doctor, "Hi Sam,") || strings.Contains(memory.Doctor, "can't find any memory issue") {
memory.Doctor = ""
}
// 未限制内存上限时给出预警
if maxmemory, mmErr := redigo.Strings(conn.Exec("CONFIG", "GET", "maxmemory")); mmErr == nil && len(maxmemory) == 2 && maxmemory[1] == "0" {
memory.NoLimit = true
}
reply, err := conn.Exec("MEMORY", "STATS")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
values, err := redigo.Values(reply, nil)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
stats := map[string]string{}
for i := 0; i+1 < len(values); i += 2 {
key, keyErr := redigo.String(values[i], nil)
if keyErr != nil {
continue
}
checkLine := trimmed
if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
// 值可能为整数或字符串,嵌套数组(如 db.0)跳过
if v, vErr := redigo.Int64(values[i+1], nil); vErr == nil {
stats[key] = cast.ToString(v)
continue
}
parts := strings.Fields(checkLine)
if len(parts) >= 1 && parts[0] == key {
if found {
continue
}
found = true
// 值为空时注释掉该配置项
if value == "" {
if !strings.HasPrefix(trimmed, "#") {
result = append(result, "# "+trimmed)
} else {
result = append(result, line)
}
continue
}
result = append(result, key+" "+value)
} else {
result = append(result, line)
if v, vErr := redigo.String(values[i+1], nil); vErr == nil {
stats[key] = v
}
}
if !found && value != "" {
result = append(result, key+" "+value)
// 各版本键名存在差异,仅展示存在的指标
items := []struct {
key string
name string
bytes bool
}{
{"peak.allocated", s.t.Get("Peak Allocated"), true},
{"total.allocated", s.t.Get("Total Allocated"), true},
{"startup.allocated", s.t.Get("Startup Allocated"), true},
{"dataset.bytes", s.t.Get("Dataset Size"), true},
{"dataset.percentage", s.t.Get("Dataset Percentage"), false},
{"keys.count", s.t.Get("Keys Count"), false},
{"keys.bytes-per-key", s.t.Get("Bytes Per Key"), true},
{"allocator-fragmentation.ratio", s.t.Get("Allocator Fragmentation Ratio"), false},
{"fragmentation", s.t.Get("Fragmentation Ratio"), false},
}
return strings.Join(result, "\n")
for _, item := range items {
value, ok := stats[item.key]
if !ok {
continue
}
if item.bytes {
value = tools.FormatBytes(cast.ToFloat64(value))
}
memory.Items = append(memory.Items, types.NV{Name: item.name, Value: value})
}
service.Success(w, memory)
}
// ScanBigKeys 扫描大 Key(异步任务)
func (s *App) ScanBigKeys(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(s.confPath())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
withPassword := ""
if password := confval.Directive.Get(config, "requirepass"); password != "" {
withPassword = " -a " + password
}
task := new(biz.Task)
task.Key = s.slug + ":bigkeys"
task.Name = s.t.Get("Scan %s big keys", s.name)
task.Status = biz.TaskStatusWaiting
task.Shell = fmt.Sprintf("%s-cli%s --bigkeys", s.slug, withPassword)
if err = s.taskRepo.Push(task); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// connect 从配置文件读取端口与密码建立连接
func (s *App) connect(ctx context.Context) (*db.Redis, error) {
config, err := io.Read(s.confPath())
if err != nil {
return nil, err
}
port := confval.Directive.Get(config, "port")
if port == "" {
port = "6379"
}
password := confval.Directive.Get(config, "requirepass")
return db.NewRedis(ctx, "", password, "127.0.0.1:"+port)
}
func (s *App) confPath() string {
return filepath.Join(app.Root, "server", s.slug, s.slug+".conf")
}
+41 -11
View File
@@ -1,22 +1,52 @@
package redis
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
import "github.com/acepanel/panel/v3/pkg/types"
// ConfigTune Redis 配置调整
// ConfigTune Redis 协议兼容服务的配置调整
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"`
}
// ClientKill 踢除客户端连接请求
type ClientKill struct {
ID int64 `form:"id" json:"id" validate:"required"`
}
// SlowLogEntry 慢日志条目
type SlowLogEntry struct {
ID int64 `json:"id"`
Time string `json:"time"`
DurationUs int64 `json:"duration_us"`
Command string `json:"command"`
Client string `json:"client"`
}
// Client 客户端连接信息
type Client struct {
ID string `json:"id"`
Addr string `json:"addr"`
Name string `json:"name"`
DB string `json:"db"`
Age string `json:"age"`
Idle string `json:"idle"`
Cmd string `json:"cmd"`
}
// Memory 内存诊断信息
type Memory struct {
Doctor string `json:"doctor"`
NoLimit bool `json:"no_limit"`
Items []types.NV `json:"items"`
}
+240
View File
@@ -0,0 +1,240 @@
package rocketmq
import (
"fmt"
"net/http"
"regexp"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
r.Get("/load", s.Load)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Get("/config_tune", s.GetConfigTune)
r.Post("/config_tune", s.UpdateConfigTune)
}
func (s *App) Status() string {
namesrv, _ := systemctl.Status("rocketmq-namesrv")
broker, _ := systemctl.Status("rocketmq-broker")
return types.AggregateAppStatus(namesrv, broker)
}
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
namesrvStatus, _ := systemctl.Status("rocketmq-namesrv")
brokerStatus, _ := systemctl.Status("rocketmq-broker")
namesrvStr := "stopped"
if namesrvStatus {
namesrvStr = "running"
}
brokerStr := "stopped"
if brokerStatus {
brokerStr = "running"
}
if !namesrvStatus && !brokerStatus {
service.Success(w, []types.NV{})
return
}
config, _ := io.Read(s.configPath())
data := []types.NV{
{Name: s.t.Get("NameServer Status"), Value: namesrvStr},
{Name: s.t.Get("Broker Status"), Value: brokerStr},
{Name: s.t.Get("Broker Name"), Value: confval.Properties.Get(config, "brokerName")},
{Name: s.t.Get("Listen Port"), Value: confval.Properties.Get(config, "listenPort")},
{Name: s.t.Get("NameServer Address"), Value: confval.Properties.Get(config, "namesrvAddr")},
{Name: s.t.Get("Broker Role"), Value: confval.Properties.Get(config, "brokerRole")},
{Name: s.t.Get("Flush Disk Type"), Value: confval.Properties.Get(config, "flushDiskType")},
}
service.Success(w, data)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
conf, _ := io.Read(s.configPath())
service.Success(w, conf)
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write(s.configPath(), req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = s.restartServices(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// GetConfigTune 获取 RocketMQ 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(s.configPath())
heapRaw, _ := io.Read(s.heapEnvPath())
namesrvInit, namesrvMax := s.parseHeapLine(heapRaw, "ROCKETMQ_NAMESRV_HEAP")
brokerInit, brokerMax := s.parseHeapLine(heapRaw, "ROCKETMQ_BROKER_HEAP")
tune := ConfigTune{
BrokerName: confval.Properties.Get(config, "brokerName"),
ListenPort: confval.Properties.Get(config, "listenPort"),
NamesrvAddr: confval.Properties.Get(config, "namesrvAddr"),
BrokerRole: confval.Properties.Get(config, "brokerRole"),
FlushDiskType: confval.Properties.Get(config, "flushDiskType"),
StorePathRootDir: confval.Properties.Get(config, "storePathRootDir"),
StorePathCommitLog: confval.Properties.Get(config, "storePathCommitLog"),
MaxMessageSize: confval.Properties.Get(config, "maxMessageSize"),
NamesrvHeapInitSize: namesrvInit,
NamesrvHeapMaxSize: namesrvMax,
BrokerHeapInitSize: brokerInit,
BrokerHeapMaxSize: brokerMax,
}
service.Success(w, tune)
}
// UpdateConfigTune 更新 RocketMQ 配置调整参数
func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ConfigTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
config, _ := io.Read(s.configPath())
config = confval.Properties.Set(config, "brokerName", req.BrokerName)
config = confval.Properties.Set(config, "listenPort", req.ListenPort)
config = confval.Properties.Set(config, "namesrvAddr", req.NamesrvAddr)
config = confval.Properties.Set(config, "brokerRole", req.BrokerRole)
config = confval.Properties.Set(config, "flushDiskType", req.FlushDiskType)
config = confval.Properties.Set(config, "storePathRootDir", req.StorePathRootDir)
config = confval.Properties.Set(config, "storePathCommitLog", req.StorePathCommitLog)
config = confval.Properties.Set(config, "maxMessageSize", req.MaxMessageSize)
if err = io.Write(s.configPath(), config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 更新 JVM 堆内存
if req.NamesrvHeapInitSize != "" || req.NamesrvHeapMaxSize != "" || req.BrokerHeapInitSize != "" || req.BrokerHeapMaxSize != "" {
heapRaw, _ := io.Read(s.heapEnvPath())
heapRaw = s.setHeapEnv(heapRaw, *req)
if err = io.Write(s.heapEnvPath(), heapRaw, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
if err = s.restartServices(); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// restartServices 重启 NameServer 和 Broker 服务
func (s *App) restartServices() error {
if err := systemctl.Restart("rocketmq-namesrv"); err != nil {
return err
}
return systemctl.Restart("rocketmq-broker")
}
// configPath 返回 broker 配置文件路径
func (s *App) configPath() string {
return app.Root + "/server/rocketmq/conf/broker.conf"
}
// heapEnvPath 返回 JVM 堆内存配置文件路径
func (s *App) heapEnvPath() string {
return app.Root + "/server/rocketmq/conf/heap.env"
}
// parseHeapLine 从 heap.env 中提取指定环境变量的堆内存配置
func (s *App) parseHeapLine(content string, envKey string) (initSize, maxSize string) {
re := regexp.MustCompile(envKey + `=(.+)`)
m := re.FindStringSubmatch(content)
if len(m) != 2 {
return
}
opts := m[1]
if mi := regexp.MustCompile(`-Xms(\S+)`).FindStringSubmatch(opts); len(mi) == 2 {
initSize = mi[1]
}
if mx := regexp.MustCompile(`-Xmx(\S+)`).FindStringSubmatch(opts); len(mx) == 2 {
maxSize = mx[1]
}
return
}
// setHeapEnv 设置 heap.env 中的堆内存配置
func (s *App) setHeapEnv(content string, req ConfigTune) string {
// 读取已有值作为默认
oldNsInit, oldNsMax := s.parseHeapLine(content, "ROCKETMQ_NAMESRV_HEAP")
oldBrInit, oldBrMax := s.parseHeapLine(content, "ROCKETMQ_BROKER_HEAP")
nsInit := req.NamesrvHeapInitSize
if nsInit == "" {
nsInit = oldNsInit
}
if nsInit == "" {
nsInit = "512m"
}
nsMax := req.NamesrvHeapMaxSize
if nsMax == "" {
nsMax = oldNsMax
}
if nsMax == "" {
nsMax = "512m"
}
brInit := req.BrokerHeapInitSize
if brInit == "" {
brInit = oldBrInit
}
if brInit == "" {
brInit = "1g"
}
brMax := req.BrokerHeapMaxSize
if brMax == "" {
brMax = oldBrMax
}
if brMax == "" {
brMax = "1g"
}
return fmt.Sprintf("ROCKETMQ_NAMESRV_HEAP=-Xms%s -Xmx%s\nROCKETMQ_BROKER_HEAP=-Xms%s -Xmx%s\n", nsInit, nsMax, brInit, brMax)
}
+26
View File
@@ -0,0 +1,26 @@
package rocketmq
// UpdateConfig RocketMQ 配置更新
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// ConfigTune RocketMQ 配置调整
type ConfigTune struct {
// Broker 基础
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" 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)
// JVM - Broker
BrokerHeapInitSize string `form:"broker_heap_init_size" json:"broker_heap_init_size"` // -Xms (broker)
BrokerHeapMaxSize string `form:"broker_heap_max_size" json:"broker_heap_max_size"` // -Xmx (broker)
}
+33 -177
View File
@@ -1,20 +1,17 @@
package rsync
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/utils/str"
"github.com/libtnb/chix/v2"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
@@ -22,6 +19,7 @@ type App struct {
}
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}
@@ -36,63 +34,18 @@ func (s *App) Route(r chi.Router) {
r.Post("/config", s.UpdateConfig)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("rsyncd")
return types.AggregateAppStatus(ok)
}
func (s *App) List(w http.ResponseWriter, r *http.Request) {
config, err := io.Read("/etc/rsyncd.conf")
modules, err := listModules()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
var modules []Module
lines := strings.Split(config, "\n")
var currentModule *Module
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
if currentModule != nil {
modules = append(modules, *currentModule)
}
moduleName := line[1 : len(line)-1]
currentModule = &Module{
Name: moduleName,
}
} else if currentModule != nil {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "path":
currentModule.Path = value
case "comment":
currentModule.Comment = value
case "read only":
currentModule.ReadOnly = value == "yes" || value == "true"
case "auth users":
currentModule.AuthUser = value
currentModule.Secret, err = shell.Execf(`grep -E '^%s:.*$' /etc/rsyncd.secrets | awk -F ':' '{print $2}'`, currentModule.AuthUser)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get the secret key for module %s", currentModule.AuthUser))
return
}
case "hosts allow":
currentModule.HostsAllow = value
}
}
}
}
if currentModule != nil {
modules = append(modules, *currentModule)
}
paged, total := service.Paginate(r, modules)
service.Success(w, chix.M{
@@ -102,136 +55,47 @@ func (s *App) List(w http.ResponseWriter, r *http.Request) {
}
func (s *App) Create(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Create](r)
req, err := service.Bind[Module](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
config, err := io.Read("/etc/rsyncd.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if strings.Contains(config, "["+req.Name+"]") {
if io.Exists(modulePath(req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("module %s already exists", req.Name))
return
}
conf := `# ` + req.Name + `-START
[` + req.Name + `]
path = ` + req.Path + `
comment = ` + req.Comment + `
read only = no
auth users = ` + req.AuthUser + `
hosts allow = ` + req.HostsAllow + `
secrets file = /etc/rsyncd.secrets
# ` + req.Name + `-END
`
if err = io.WriteAppend("/etc/rsyncd.conf", conf, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.WriteAppend("/etc/rsyncd.secrets", fmt.Sprintf("%s:%s\n", req.AuthUser, req.Secret), 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("rsyncd"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
func (s *App) Delete(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Delete](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
config, err := io.Read("/etc/rsyncd.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if !strings.Contains(config, "["+req.Name+"]") {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("module %s does not exist", req.Name))
return
}
module := str.Cut(config, "# "+req.Name+"-START", "# "+req.Name+"-END")
config = strings.ReplaceAll(config, "\n# "+req.Name+"-START"+module+"# "+req.Name+"-END", "")
match := regexp.MustCompile(`auth users = ([^\n]+)`).FindStringSubmatch(module)
if len(match) == 2 {
authUser := match[1]
if _, err = shell.Execf(`sed -i '/^%s:.*$/d' /etc/rsyncd.secrets`, authUser); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
if err = io.Write("/etc/rsyncd.conf", config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("rsyncd"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
s.save(w, req)
}
func (s *App) Update(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Update](r)
req, err := service.Bind[Module](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
config, err := io.Read("/etc/rsyncd.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if !strings.Contains(config, "["+req.Name+"]") {
if !io.Exists(modulePath(req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("module %s does not exist", req.Name))
return
}
newConf := `# ` + req.Name + `-START
[` + req.Name + `]
path = ` + req.Path + `
comment = ` + req.Comment + `
read only = no
auth users = ` + req.AuthUser + `
hosts allow = ` + req.HostsAllow + `
secrets file = /etc/rsyncd.secrets
# ` + req.Name + `-END`
s.save(w, req)
}
module := str.Cut(config, "# "+req.Name+"-START", "# "+req.Name+"-END")
config = strings.ReplaceAll(config, "# "+req.Name+"-START"+module+"# "+req.Name+"-END", newConf)
match := regexp.MustCompile(`auth users = ([^\n]+)`).FindStringSubmatch(module)
if len(match) == 2 {
authUser := match[1]
if _, err = shell.Execf(`sed -i '/^%s:.*$/d' /etc/rsyncd.secrets`, authUser); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
func (s *App) Delete(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ModuleName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = io.Write("/etc/rsyncd.conf", config, 0644); err != nil {
if err = io.Remove(modulePath(req.Name)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.WriteAppend("/etc/rsyncd.secrets", fmt.Sprintf("%s:%s\n", req.AuthUser, req.Secret), 0600); err != nil {
if err = io.Remove(secretsPath(req.Name)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -245,28 +109,20 @@ secrets file = /etc/rsyncd.secrets
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read("/etc/rsyncd.conf")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, config)
common.ServeConfig(w, rsyncdConf)
}
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
common.SaveConfig(w, r, rsyncdConf, "rsyncd")
}
if err = io.Write("/etc/rsyncd.conf", req.Config, 0644); err != nil {
func (s *App) save(w http.ResponseWriter, req *Module) {
if err := writeModule(*req); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("rsyncd"); err != nil {
if err := systemctl.Restart("rsyncd"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
+2 -24
View File
@@ -1,27 +1,5 @@
package rsync
type Create struct {
Name string `form:"name" json:"name" validate:"required"`
Path string `form:"path" json:"path" validate:"required"`
Comment string `form:"comment" json:"comment"`
AuthUser string `form:"auth_user" json:"auth_user" validate:"required"`
Secret string `form:"secret" json:"secret" validate:"required"`
HostsAllow string `form:"hosts_allow" json:"hosts_allow"`
}
type Delete struct {
Name string `form:"name" json:"name" validate:"required"`
}
type Update struct {
Name string `form:"name" json:"name" validate:"required"`
Path string `form:"path" json:"path" validate:"required"`
Comment string `form:"comment" json:"comment"`
AuthUser string `form:"auth_user" json:"auth_user" validate:"required"`
Secret string `form:"secret" json:"secret" validate:"required"`
HostsAllow string `form:"hosts_allow" json:"hosts_allow"`
}
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
type ModuleName struct {
Name string `form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
}
+84
View File
@@ -0,0 +1,84 @@
package rsync
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/samber/lo"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/pkg/io"
)
const (
rsyncdConf = "/etc/rsyncd.conf"
rsyncdDir = "/etc/rsyncd.d"
)
func modulePath(name string) string {
return filepath.Join(rsyncdDir, name+".conf")
}
// secretsPath 一个模块独立的密钥文件,.secrets 后缀不会被 rsyncd 当作模块读入
func secretsPath(name string) string {
return filepath.Join(rsyncdDir, name+".secrets")
}
func readModule(name string) (Module, error) {
config, err := io.Read(modulePath(name))
if err != nil {
return Module{}, err
}
secret, _ := io.Read(secretsPath(name))
_, secret, _ = strings.Cut(strings.TrimSpace(secret), ":")
return Module{
Name: name,
Path: confval.SectionINI.GetIn(config, name, "path"),
Comment: confval.SectionINI.GetIn(config, name, "comment"),
ReadOnly: confval.SectionINI.GetIn(config, name, "read only") == "yes",
AuthUser: confval.SectionINI.GetIn(config, name, "auth users"),
HostsAllow: confval.SectionINI.GetIn(config, name, "hosts allow"),
Secret: secret,
}, nil
}
func listModules() ([]Module, error) {
// Glob 在目录不存在时返回空,无需额外判断
paths, err := filepath.Glob(modulePath("*"))
if err != nil {
return nil, err
}
modules := make([]Module, 0, len(paths))
for _, path := range paths {
module, err := readModule(strings.TrimSuffix(filepath.Base(path), ".conf"))
if err != nil {
return nil, err
}
modules = append(modules, module)
}
return modules, nil
}
func writeModule(module Module) error {
if err := os.MkdirAll(rsyncdDir, 0755); err != nil {
return err
}
config := fmt.Sprintf(
"[%s]\npath = %s\ncomment = %s\nread only = %s\nauth users = %s\nhosts allow = %s\nsecrets file = %s\n",
module.Name, module.Path, module.Comment, lo.Ternary(module.ReadOnly, "yes", "no"),
module.AuthUser, module.HostsAllow, secretsPath(module.Name),
)
if err := io.Write(modulePath(module.Name), config, 0644); err != nil {
return err
}
return io.Write(secretsPath(module.Name), module.AuthUser+":"+module.Secret+"\n", 0600)
}
+4 -4
View File
@@ -1,11 +1,11 @@
package rsync
type Module struct {
Name string `json:"name"`
Path string `json:"path"`
Name string `form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
Path string `json:"path" validate:"required && unix_path"`
Comment string `json:"comment"`
ReadOnly bool `json:"read_only"`
AuthUser string `json:"auth_user"`
Secret string `json:"secret"`
AuthUser string `json:"auth_user" validate:"required"`
Secret string `json:"secret" validate:"required"`
HostsAllow string `json:"hosts_allow"`
}
+17 -18
View File
@@ -9,12 +9,14 @@ 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"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
@@ -22,6 +24,7 @@ type App struct {
}
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}
@@ -33,6 +36,10 @@ func (s *App) Route(r chi.Router) {
r.Delete("/mounts", s.Delete)
}
func (s *App) Status() string {
return types.AppStatusNA
}
// List 所有 S3fs 挂载
func (s *App) List(w http.ResponseWriter, r *http.Request) {
list, err := s.mounts()
@@ -81,11 +88,9 @@ func (s *App) Create(w http.ResponseWriter, r *http.Request) {
return
}
for _, item := range list {
if item.Path == req.Path {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("mount path already exists"))
return
}
if lo.ContainsBy(list, func(item Mount) bool { return item.Path == req.Path }) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("mount path already exists"))
return
}
id := time.Now().UnixMicro()
@@ -128,14 +133,8 @@ func (s *App) Delete(w http.ResponseWriter, r *http.Request) {
return
}
var mount Mount
for _, item := range list {
if item.ID == req.ID {
mount = item
break
}
}
if mount.ID == 0 {
mount, ok := lo.Find(list, func(item Mount) bool { return item.ID == req.ID })
if !ok {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("mount not found"))
return
}
@@ -178,7 +177,7 @@ func (s *App) mounts() ([]Mount, error) {
if err != nil {
return nil, err
}
for _, id := range strings.Split(ids, "\n") {
for id := range strings.SplitSeq(ids, "\n") {
if id == "" {
continue
}
+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 {
+133 -164
View File
@@ -6,14 +6,18 @@ 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"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/os"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/v3/internal/apps/common"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/os"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
@@ -22,22 +26,14 @@ type App struct {
}
func NewApp(t *gotext.Locale) *App {
var name string
if os.IsRHEL() {
name = "supervisord"
} else {
name = "supervisor"
}
return &App{
t: t,
name: name,
name: lo.Ternary(os.IsRHEL(), "supervisord", "supervisor"),
}
}
func (s *App) Route(r chi.Router) {
r.Get("/service", s.Service)
r.Post("/clear_log", s.ClearLog)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
r.Get("/processes", s.Processes)
@@ -45,11 +41,12 @@ func (s *App) Route(r chi.Router) {
r.Post("/processes/{process}/stop", s.StopProcess)
r.Post("/processes/{process}/restart", s.RestartProcess)
r.Get("/processes/{process}/log", s.ProcessLog)
r.Post("/processes/{process}/clear_log", s.ClearProcessLog)
r.Get("/processes/{process}", s.ProcessConfig)
r.Post("/processes/{process}", s.UpdateProcessConfig)
r.Delete("/processes/{process}", s.DeleteProcess)
r.Get("/processes/{process}/setting", s.GetProcessSetting)
r.Post("/processes/{process}/setting", s.UpdateProcessSetting)
r.Post("/processes", s.CreateProcess)
r.Delete("/processes/{process}", s.DeleteProcess)
}
// Service 获取服务名称
@@ -57,88 +54,52 @@ func (s *App) Service(w http.ResponseWriter, r *http.Request) {
service.Success(w, s.name)
}
// ClearLog 清空日志
func (s *App) ClearLog(w http.ResponseWriter, r *http.Request) {
if _, err := shell.Execf(`cat /dev/null > /var/log/supervisor/supervisord.log`); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
func (s *App) Status() string {
ok, _ := systemctl.Status(s.name)
return types.AggregateAppStatus(ok)
}
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
var config string
var err error
if os.IsRHEL() {
config, err = io.Read(`/etc/supervisord.conf`)
} else {
config, err = io.Read(`/etc/supervisor/supervisord.conf`)
}
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, config)
common.ServeConfig(w, mainConfPath())
}
// UpdateConfig 保存配置
func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateConfig](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if os.IsRHEL() {
err = io.Write(`/etc/supervisord.conf`, req.Config, 0644)
} else {
err = io.Write(`/etc/supervisor/supervisord.conf`, req.Config, 0644)
}
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart(s.name); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to restart %s: %v", s.name, err))
return
}
service.Success(w, nil)
common.SaveConfig(w, r, mainConfPath(), s.name)
}
// Processes 进程列表
func (s *App) Processes(w http.ResponseWriter, r *http.Request) {
out, err := shell.Execf(`supervisorctl status | awk '{print $1}'`)
if err != nil {
out, err := shell.Execf(`supervisorctl status`)
if err != nil && out == "" {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
var processes []Process
for _, line := range strings.Split(out, "\n") {
if len(line) == 0 {
for line := range strings.SplitSeq(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
var p Process
p.Name = line
if status, err := shell.Execf(`supervisorctl status '%s' | awk '{print $2}'`, line); err == nil {
p.Status = status
p := Process{
Name: fields[0],
Status: fields[1],
Pid: "-",
Uptime: "-",
}
// RUNNING 行格式:name RUNNING pid 1234, uptime 1:23:45 (超过 1 天为 "1 day, 1:23:45"
if p.Status == "RUNNING" {
pid, _ := shell.Execf(`supervisorctl status '%s' | awk '{print $4}'`, line)
p.Pid = strings.ReplaceAll(pid, ",", "")
uptime, _ := shell.Execf(`supervisorctl status '%s' | awk '{print $6}'`, line)
p.Uptime = uptime
} else {
p.Pid = "-"
p.Uptime = "-"
if _, rest, ok := strings.Cut(line, "pid "); ok {
if pid, _, ok := strings.Cut(rest, ","); ok {
p.Pid = pid
}
}
if _, uptime, ok := strings.Cut(line, "uptime "); ok {
p.Uptime = uptime
}
}
processes = append(processes, p)
}
@@ -159,7 +120,7 @@ func (s *App) StartProcess(w http.ResponseWriter, r *http.Request) {
return
}
if out, err := shell.Execf(`supervisorctl start %s`, req.Process); err != nil {
if out, err := shell.Execf(`supervisorctl start '%s'`, req.Process); err != nil {
service.Error(w, http.StatusInternalServerError, "%v %s", err, out)
return
}
@@ -175,7 +136,7 @@ func (s *App) StopProcess(w http.ResponseWriter, r *http.Request) {
return
}
if out, err := shell.Execf(`supervisorctl stop %s`, req.Process); err != nil {
if out, err := shell.Execf(`supervisorctl stop '%s'`, req.Process); err != nil {
service.Error(w, http.StatusInternalServerError, "%v %s", err, out)
return
}
@@ -191,7 +152,7 @@ func (s *App) RestartProcess(w http.ResponseWriter, r *http.Request) {
return
}
if out, err := shell.Execf(`supervisorctl restart %s`, req.Process); err != nil {
if out, err := shell.Execf(`supervisorctl restart '%s'`, req.Process); err != nil {
service.Error(w, http.StatusInternalServerError, "%v %s", err, out)
return
}
@@ -207,13 +168,7 @@ func (s *App) ProcessLog(w http.ResponseWriter, r *http.Request) {
return
}
var logPath string
if os.IsRHEL() {
logPath, err = shell.Execf(`cat '/etc/supervisord.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process)
} else {
logPath, err = shell.Execf(`cat '/etc/supervisor/conf.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process)
}
logPath, err := s.processLog(req.Process)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get log path for process %s: %v", req.Process, err))
return
@@ -222,35 +177,7 @@ func (s *App) ProcessLog(w http.ResponseWriter, r *http.Request) {
service.Success(w, logPath)
}
// ClearProcessLog 清空进程日志
func (s *App) ClearProcessLog(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ProcessName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
var logPath string
if os.IsRHEL() {
logPath, err = shell.Execf(`cat '/etc/supervisord.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process)
} else {
logPath, err = shell.Execf(`cat '/etc/supervisor/conf.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process)
}
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get log path for process %s: %v", req.Process, err))
return
}
if _, err = shell.Execf(`cat /dev/null > '%s'`, logPath); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// ProcessConfig 获取进程配置
// ProcessConfig 获取进程配置原文
func (s *App) ProcessConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ProcessName](r)
if err != nil {
@@ -258,13 +185,7 @@ func (s *App) ProcessConfig(w http.ResponseWriter, r *http.Request) {
return
}
var config string
if os.IsRHEL() {
config, err = io.Read(`/etc/supervisord.d/` + req.Process + `.conf`)
} else {
config, err = io.Read(`/etc/supervisor/conf.d/` + req.Process + `.conf`)
}
config, err := io.Read(confPath(programName(req.Process)))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -273,7 +194,7 @@ func (s *App) ProcessConfig(w http.ResponseWriter, r *http.Request) {
service.Success(w, config)
}
// UpdateProcessConfig 保存进程配置
// UpdateProcessConfig 保存进程配置原文
func (s *App) UpdateProcessConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateProcessConfig](r)
if err != nil {
@@ -281,22 +202,57 @@ func (s *App) UpdateProcessConfig(w http.ResponseWriter, r *http.Request) {
return
}
if os.IsRHEL() {
err = io.Write(`/etc/supervisord.d/`+req.Process+`.conf`, req.Config, 0644)
} else {
err = io.Write(`/etc/supervisor/conf.d/`+req.Process+`.conf`, req.Config, 0644)
name := programName(req.Process)
if err = io.Write(confPath(name), req.Config, 0644); err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
s.reload(w, name)
}
// GetProcessSetting 获取进程可视化参数
func (s *App) GetProcessSetting(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ProcessName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
_, _ = shell.Execf(`supervisorctl reread`)
_, _ = shell.Execf(`supervisorctl update`)
_, _ = shell.Execf(`supervisorctl restart '%s'`, req.Process)
config, err := io.Read(confPath(programName(req.Process)))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
setting := new(ProcessSetting)
readSetting(config, setting)
service.Success(w, setting)
}
// UpdateProcessSetting 保存进程可视化参数
func (s *App) UpdateProcessSetting(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateProcessSetting](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
name := programName(req.Process)
path := confPath(name)
config, err := io.Read(path)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(path, writeSetting(config, &req.ProcessSetting), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
s.reload(w, name)
}
// CreateProcess 添加进程
@@ -307,33 +263,34 @@ func (s *App) CreateProcess(w http.ResponseWriter, r *http.Request) {
return
}
path := confPath(req.Name)
if io.Exists(path) {
service.Error(w, http.StatusConflict, s.t.Get("process %s already exists", req.Name))
return
}
num := cast.ToString(req.Num)
config := `[program:` + req.Name + `]
command=` + req.Command + `
process_name=%(program_name)s
process_name=` + processNameExpr(num) + `
directory=` + req.Path + `
autostart=true
autorestart=true
user=` + req.User + `
numprocs=` + cast.ToString(req.Num) + `
numprocs=` + num + `
redirect_stderr=true
stdout_logfile=/var/log/supervisor/` + req.Name + `.log
stdout_logfile_maxbytes=2MB
`
if os.IsRHEL() {
err = io.Write(`/etc/supervisord.d/`+req.Name+`.conf`, config, 0644)
} else {
err = io.Write(`/etc/supervisor/conf.d/`+req.Name+`.conf`, config, 0644)
}
if err != nil {
if err = io.Write(path, config, 0644); err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
_, _ = shell.Execf(`supervisorctl reread`)
_, _ = shell.Execf(`supervisorctl update`)
_, _ = shell.Execf(`supervisorctl start '%s'`, req.Name)
_, _ = shell.Execf(`supervisorctl start '%s:'`, req.Name)
service.Success(w, nil)
}
@@ -346,40 +303,52 @@ func (s *App) DeleteProcess(w http.ResponseWriter, r *http.Request) {
return
}
if out, err := shell.Execf(`supervisorctl stop '%s'`, req.Process); err != nil {
name := programName(req.Process)
if out, err := shell.Execf(`supervisorctl stop '%s:'`, name); err != nil {
service.Error(w, http.StatusInternalServerError, "%v %s", err, out)
return
}
var logPath string
if os.IsRHEL() {
logPath, err = shell.Execf(`cat '/etc/supervisord.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get log path for process %s: %v", req.Process, err))
return
}
if err = io.Remove(`/etc/supervisord.d/` + req.Process + `.conf`); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
} else {
logPath, err = shell.Execf(`cat '/etc/supervisor/conf.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, req.Process)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get log path for process %s: %v", req.Process, err))
return
}
if err = io.Remove(`/etc/supervisor/conf.d/` + req.Process + `.conf`); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
logPath, err := s.processLog(req.Process)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get log path for process %s: %v", req.Process, err))
return
}
if err = io.Remove(confPath(name)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Remove(logPath); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
_, _ = shell.Execf(`supervisorctl reread`)
_, _ = shell.Execf(`supervisorctl update`)
service.Success(w, nil)
}
func (s *App) processLog(process string) (string, error) {
config, err := io.Read(confPath(programName(process)))
if err != nil {
return "", err
}
return confval.Supervisor.Get(config, "stdout_logfile"), nil
}
func (s *App) reload(w http.ResponseWriter, name string) {
_, _ = shell.Execf(`supervisorctl reread`)
_, _ = shell.Execf(`supervisorctl update`)
_, _ = shell.Execf(`supervisorctl restart '%s:'`, name)
service.Success(w, nil)
}
// programName 从 supervisorctl 的显示名还原出程序名,program 名本身不允许含 ":",切分无歧义
func programName(process string) string {
name, _, _ := strings.Cut(process, ":")
return name
}
+63
View File
@@ -0,0 +1,63 @@
package supervisor
import (
"github.com/samber/lo"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/pkg/os"
)
// mainConfPath supervisor 主配置,RHEL 系与 Debian 系的路径不同
func mainConfPath() string {
return lo.Ternary(os.IsRHEL(), "/etc/supervisord.conf", "/etc/supervisor/supervisord.conf")
}
func confPath(name string) string {
return lo.Ternary(os.IsRHEL(), "/etc/supervisord.d/", "/etc/supervisor/conf.d/") + name + ".conf"
}
type settingField struct {
key string
value *string
}
func settingFields(s *ProcessSetting) []settingField {
return []settingField{
{"command", &s.Command},
{"directory", &s.Directory},
{"user", &s.User},
{"numprocs", &s.NumProcs},
{"priority", &s.Priority},
{"autostart", &s.AutoStart},
{"autorestart", &s.AutoRestart},
{"startsecs", &s.StartSecs},
{"startretries", &s.StartRetries},
{"stopwaitsecs", &s.StopWaitSecs},
{"stopasgroup", &s.StopAsGroup},
{"killasgroup", &s.KillAsGroup},
{"redirect_stderr", &s.RedirectStderr},
{"stdout_logfile", &s.StdoutLogfile},
{"stdout_logfile_maxbytes", &s.StdoutLogfileMaxBytes},
{"stdout_logfile_backups", &s.StdoutLogfileBackups},
{"environment", &s.Environment},
}
}
func readSetting(config string, setting *ProcessSetting) {
for _, f := range settingFields(setting) {
*f.value = confval.Supervisor.Get(config, f.key)
}
}
// writeSetting numprocs 大于 1 时 process_name 必须带上 process_num,否则 supervisor 会拒绝加载
func writeSetting(config string, setting *ProcessSetting) string {
for _, f := range settingFields(setting) {
config = confval.Supervisor.Set(config, f.key, *f.value)
}
return confval.Supervisor.Set(config, "process_name", processNameExpr(setting.NumProcs))
}
func processNameExpr(numProcs string) string {
return lo.Ternary(numProcs == "" || numProcs == "1", `%(program_name)s`, `%(program_name)s_%(process_num)02d`)
}
+7 -6
View File
@@ -1,9 +1,5 @@
package supervisor
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
type UpdateProcessConfig struct {
Process string `form:"process" json:"process" validate:"required"`
Config string `form:"config" json:"config" validate:"required"`
@@ -13,10 +9,15 @@ type ProcessName struct {
Process string `form:"process" json:"process" validate:"required"`
}
type UpdateProcessSetting struct {
Process string `form:"process" json:"process" validate:"required"`
ProcessSetting
}
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"`
}
+21
View File
@@ -6,3 +6,24 @@ type Process struct {
Pid string `json:"pid"`
Uptime string `json:"uptime"`
}
// ProcessSetting 进程的可视化参数,留空表示注释掉该项、走 supervisor 默认值
type ProcessSetting struct {
Command string `form:"command" json:"command" validate:"required"`
Directory string `form:"directory" json:"directory"`
User string `form:"user" json:"user"`
NumProcs string `form:"numprocs" json:"numprocs"`
Priority string `form:"priority" json:"priority"`
AutoStart string `form:"autostart" json:"autostart"`
AutoRestart string `form:"autorestart" json:"autorestart"`
StartSecs string `form:"startsecs" json:"startsecs"`
StartRetries string `form:"startretries" json:"startretries"`
StopWaitSecs string `form:"stopwaitsecs" json:"stopwaitsecs"`
StopAsGroup string `form:"stopasgroup" json:"stopasgroup"`
KillAsGroup string `form:"killasgroup" json:"killasgroup"`
RedirectStderr string `form:"redirect_stderr" json:"redirect_stderr"`
StdoutLogfile string `form:"stdout_logfile" json:"stdout_logfile"`
StdoutLogfileMaxBytes string `form:"stdout_logfile_maxbytes" json:"stdout_logfile_maxbytes"`
StdoutLogfileBackups string `form:"stdout_logfile_backups" json:"stdout_logfile_backups"`
Environment string `form:"environment" json:"environment"`
}

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