Compare commits

..

105 Commits

Author SHA1 Message Date
耗子 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
494 changed files with 42398 additions and 26664 deletions
+15 -8
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
@@ -36,14 +36,21 @@ 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
with:
+13 -6
View File
@@ -3,6 +3,9 @@ on:
push:
branches:
- main
concurrency:
group: codegen
cancel-in-progress: false
permissions:
contents: write
jobs:
@@ -19,11 +22,15 @@ jobs:
- name: Install Mockery
run: |
go install github.com/vektra/mockery/v2@latest
- name: Generate Mocks
- 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
+154 -723
View File
@@ -8,7 +8,6 @@ package main
import (
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/acewaf"
"github.com/acepanel/panel/v3/internal/apps/apache"
"github.com/acepanel/panel/v3/internal/apps/clickhouse"
"github.com/acepanel/panel/v3/internal/apps/codeserver"
@@ -57,777 +56,221 @@ import (
// Injectors from wire.go:
func initAce() (*app.Ace, func(), error) {
acewafApp, err := acewaf.NewApp()
if err != nil {
return nil, nil, err
}
config, err := bootstrap.NewConf()
if err != nil {
return nil, nil, err
}
locale, err := bootstrap.NewT(config)
if err != nil {
return nil, nil, err
}
apacheApp, err := apache.NewApp(locale)
if err != nil {
return nil, nil, err
}
locale := bootstrap.NewT(config)
apacheApp := apache.NewApp(locale)
db, err := bootstrap.NewDB(config)
if err != nil {
return nil, nil, err
}
databaseServerRepo, err := data.NewDatabaseServerRepo(db)
if err != nil {
return nil, nil, err
}
settingRepo, err := data.NewSettingRepo(config, db)
if err != nil {
return nil, nil, err
}
clickhouseApp, err := clickhouse.NewApp(locale, databaseServerRepo, settingRepo)
if err != nil {
return nil, nil, err
}
codeserverApp, err := codeserver.NewApp()
if err != nil {
return nil, nil, err
}
dockerApp, err := docker.NewApp()
if err != nil {
return nil, nil, err
}
elasticsearchApp, err := elasticsearch.NewApp(locale)
if err != nil {
return nil, nil, err
}
websiteRepo, err := data.NewWebsiteRepo(db, locale, settingRepo)
if err != nil {
return nil, nil, err
}
fail2banApp, err := fail2ban.NewApp(locale, websiteRepo)
if err != nil {
return nil, nil, err
}
frpApp, err := frp.NewApp()
if err != nil {
return nil, nil, err
}
giteaApp, err := gitea.NewApp()
if err != nil {
return nil, nil, err
}
grafanaApp, err := grafana.NewApp(locale)
if err != nil {
return nil, nil, err
}
kafkaApp, err := kafka.NewApp(locale)
if err != nil {
return nil, nil, err
}
mysqlApp, err := mysql.NewApp(locale, databaseServerRepo, settingRepo)
if err != nil {
return nil, nil, err
}
mariadbApp, err := mariadb.NewApp(mysqlApp)
if err != nil {
return nil, nil, err
}
memcachedApp, err := memcached.NewApp(locale)
if err != nil {
return nil, nil, err
}
minioApp, err := minio.NewApp()
if err != nil {
return nil, nil, err
}
mongodbApp, err := mongodb.NewApp(locale, databaseServerRepo, settingRepo)
if err != nil {
return nil, nil, err
}
nginxApp, err := nginx.NewApp(locale)
if err != nil {
return nil, nil, err
}
openrestyApp, err := openresty.NewApp(nginxApp)
if err != nil {
return nil, nil, err
}
opensearchApp, err := opensearch.NewApp(locale)
if err != nil {
return nil, nil, err
}
perconaApp, err := percona.NewApp(mysqlApp)
if err != nil {
return nil, nil, err
}
pgadminApp, err := pgadmin.NewApp(config, locale, databaseServerRepo)
if err != nil {
return nil, nil, err
}
phpmyadminApp, err := phpmyadmin.NewApp(config, locale, databaseServerRepo)
if err != nil {
return nil, nil, err
}
podmanApp, err := podman.NewApp()
if err != nil {
return nil, nil, err
}
postgresqlApp, err := postgresql.NewApp(locale, databaseServerRepo, settingRepo)
if err != nil {
return nil, nil, err
}
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(locale)
giteaApp := gitea.NewApp()
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, err := data.NewNotifyChannelRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
notifyUsecase, err := biz.NewNotifyUsecase(locale, slogLogger, notifyChannelRepo, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
taskRunner, err := bootstrap.NewRunner(notifyUsecase, db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
taskRepo, err := data.NewTaskRepo(db, locale, slogLogger, taskRunner)
if err != nil {
cleanup()
return nil, nil, err
}
prometheusApp, err := prometheus.NewApp(config, locale, taskRepo)
if err != nil {
cleanup()
return nil, nil, err
}
pureftpdApp, err := pureftpd.NewApp(locale)
if err != nil {
cleanup()
return nil, nil, err
}
redisApp, err := redis.NewApp(locale, databaseServerRepo)
if err != nil {
cleanup()
return nil, nil, err
}
rocketmqApp, err := rocketmq.NewApp(locale)
if err != nil {
cleanup()
return nil, nil, err
}
rsyncApp, err := rsync.NewApp(locale)
if err != nil {
cleanup()
return nil, nil, err
}
s3fsApp, err := s3fs.NewApp(locale)
if err != nil {
cleanup()
return nil, nil, err
}
supervisorApp, err := supervisor.NewApp(locale)
if err != nil {
cleanup()
return nil, nil, err
}
valkeyApp, err := valkey.NewApp(locale, databaseServerRepo)
if err != nil {
cleanup()
return nil, nil, err
}
loader, err := bootstrap.NewLoader(acewafApp, 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)
if err != nil {
cleanup()
return nil, nil, err
}
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()
mongodbApp := mongodb.NewApp(locale, databaseServerRepo, settingRepo)
nginxApp := nginx.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, config, databaseServerRepo, settingRepo, taskRepo)
prometheusApp := prometheus.NewApp(config, locale, taskRepo)
pureftpdApp := pureftpd.NewApp(locale)
redisApp := redis.NewApp(locale, databaseServerRepo, taskRepo)
rocketmqApp := rocketmq.NewApp(locale)
rsyncApp := rsync.NewApp(locale)
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
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 {
cleanup()
return nil, nil, err
}
appRepo, err := data.NewAppRepo(config, db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
userTokenRepo, err := data.NewUserTokenRepo(config, db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
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 {
cleanup()
return nil, nil, err
}
validator, err := bootstrap.NewValidator(config, db)
if err != nil {
cleanup()
return nil, nil, err
}
validator := bootstrap.NewValidator(config, db)
alertRepo, err := data.NewAlertRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
containerRepo, err := data.NewContainerRepo()
if err != nil {
cleanup()
return nil, nil, err
}
alertUsecase, err := biz.NewAlertUsecase(notifyUsecase, loader, locale, slogLogger, alertRepo, appRepo, containerRepo, databaseServerRepo, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
alertService, err := service.NewAlertService(alertUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
cacheRepo, err := data.NewCacheRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
appUsecase, err := biz.NewAppUsecase(locale, appRepo, cacheRepo, taskRepo)
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, err := biz.NewSettingUsecase(locale, slogLogger, settingRepo, taskRepo)
if err != nil {
cleanup()
return nil, nil, err
}
appService, err := service.NewAppService(loader, appUsecase, cacheUsecase, settingUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
backupRepo, err := data.NewBackupRepo(config, db, locale, slogLogger, settingRepo, websiteRepo)
if err != nil {
cleanup()
return nil, nil, err
}
backupUsecase, err := biz.NewBackupUsecase(notifyUsecase, locale, slogLogger, backupRepo)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := service.NewBackupService(backupUsecase, taskUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
backupAccountRepo, err := data.NewBackupAccountRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
backupAccountUsecase, err := biz.NewBackupAccountUsecase(locale, slogLogger, backupAccountRepo, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
backupStorageService, err := service.NewBackupStorageService(backupAccountUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
certRepo, err := data.NewCertRepo(db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
certUsecase, err := biz.NewCertUsecase(locale, slogLogger, certRepo, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
certService, err := service.NewCertService(certUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
certAccountRepo, err := data.NewCertAccountRepo(db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
userRepo, err := data.NewUserRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
certAccountUsecase, err := biz.NewCertAccountUsecase(locale, slogLogger, certAccountRepo, userRepo)
if err != nil {
cleanup()
return nil, nil, err
}
certAccountService, err := service.NewCertAccountService(certAccountUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
certDNSRepo, err := data.NewCertDNSRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := service.NewCertDNSService(certDNSUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
containerUsecase := biz.NewContainerUsecase(containerRepo, settingRepo)
containerService, err := service.NewContainerService(containerUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
containerComposeRepo, err := data.NewContainerComposeRepo()
if err != nil {
cleanup()
return nil, nil, err
}
certDNSService := service.NewCertDNSService(certDNSUsecase)
containerUsecase := biz.NewContainerUsecase(locale, containerRepo, settingRepo, taskRepo)
containerService := service.NewContainerService(containerUsecase)
containerComposeRepo := data.NewContainerComposeRepo()
containerComposeUsecase := biz.NewContainerComposeUsecase(containerComposeRepo)
containerComposeService, err := service.NewContainerComposeService(containerComposeUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
containerImageRepo, err := data.NewContainerImageRepo()
if err != nil {
cleanup()
return nil, nil, err
}
containerImageUsecase := biz.NewContainerImageUsecase(containerImageRepo, settingRepo)
containerImageService, err := service.NewContainerImageService(containerImageUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
containerNetworkRepo, err := data.NewContainerNetworkRepo()
if err != nil {
cleanup()
return nil, nil, err
}
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, err := service.NewContainerNetworkService(containerNetworkUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
containerVolumeRepo, err := data.NewContainerVolumeRepo()
if err != nil {
cleanup()
return nil, nil, err
}
containerNetworkService := service.NewContainerNetworkService(containerNetworkUsecase)
containerVolumeRepo := data.NewContainerVolumeRepo()
containerVolumeUsecase := biz.NewContainerVolumeUsecase(containerVolumeRepo, settingRepo)
containerVolumeService, err := service.NewContainerVolumeService(containerVolumeUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
cronRepo, err := data.NewCronRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
containerVolumeService := service.NewContainerVolumeService(containerVolumeUsecase)
cronRepo := data.NewCronRepo(db, locale)
cronUsecase := biz.NewCronUsecase(cronRepo, slogLogger)
cronService, err := service.NewCronService(cronUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUserRepo, err := data.NewDatabaseUserRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUserUsecase, err := biz.NewDatabaseUserUsecase(slogLogger, databaseServerRepo, databaseUserRepo)
if err != nil {
cleanup()
return nil, nil, err
}
databaseRepo, err := data.NewDatabaseRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUsecase, err := biz.NewDatabaseUsecase(databaseUserUsecase, locale, slogLogger, databaseRepo, databaseServerRepo)
if err != nil {
cleanup()
return nil, nil, err
}
databaseService, err := service.NewDatabaseService(databaseUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
databaseElasticsearchRepo, err := data.NewDatabaseElasticsearchRepo(db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := service.NewDatabaseElasticsearchService(databaseElasticsearchUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
databaseRedisRepo, err := data.NewDatabaseRedisRepo(db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
databaseElasticsearchService := service.NewDatabaseElasticsearchService(databaseElasticsearchUsecase)
databaseRedisRepo := data.NewDatabaseRedisRepo(db, locale, slogLogger)
databaseRedisUsecase := biz.NewDatabaseRedisUsecase(databaseRedisRepo)
databaseRedisService, err := service.NewDatabaseRedisService(databaseRedisUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
databaseServerUsecase, err := biz.NewDatabaseServerUsecase(locale, slogLogger, databaseServerRepo)
if err != nil {
cleanup()
return nil, nil, err
}
databaseServerService, err := service.NewDatabaseServerService(databaseServerUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUserService, err := service.NewDatabaseUserService(databaseUserUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
environmentRepo, err := data.NewEnvironmentRepo(config, locale)
if err != nil {
cleanup()
return nil, nil, err
}
environmentUsecase, err := biz.NewEnvironmentUsecase(locale, cacheRepo, environmentRepo, taskRepo)
if err != nil {
cleanup()
return nil, nil, err
}
environmentService, err := service.NewEnvironmentService(environmentUsecase, taskUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
environmentDotnetService, err := service.NewEnvironmentDotnetService(environmentUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
environmentGoService, err := service.NewEnvironmentGoService(environmentUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
environmentJavaService, err := service.NewEnvironmentJavaService(environmentUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
environmentNodejsService, err := service.NewEnvironmentNodejsService(environmentUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
environmentPHPService, err := service.NewEnvironmentPHPService(environmentUsecase, taskUsecase, config, locale)
if err != nil {
cleanup()
return nil, nil, err
}
environmentPythonService, err := service.NewEnvironmentPythonService(environmentUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := biz.NewTamperUsecase(notifyUsecase, settingUsecase, locale, slogLogger, tamperRepo)
if err != nil {
cleanup()
return nil, nil, err
}
fileService, err := service.NewFileService(containerUsecase, tamperUsecase, taskUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
fileShareRepo, err := data.NewFileShareRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
fileShareUsecase, err := biz.NewFileShareUsecase(slogLogger, fileShareRepo)
if err != nil {
cleanup()
return nil, nil, err
}
fileShareService, err := service.NewFileShareService(fileShareUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
firewallService, err := service.NewFirewallService(locale)
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, err := service.NewFirewallScanService(scanEventUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
projectRepo, err := data.NewProjectRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
projectUsecase, err := biz.NewProjectUsecase(locale, slogLogger, projectRepo)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := biz.NewWebsiteUsecase(certAccountUsecase, certUsecase, databaseUsecase, databaseUserUsecase, tamperUsecase, websiteStatUsecase, locale, slogLogger, databaseServerRepo, websiteRepo)
if err != nil {
cleanup()
return nil, nil, err
}
homeService, err := service.NewHomeService(appUsecase, backupUsecase, containerUsecase, cronUsecase, databaseServerUsecase, environmentUsecase, projectUsecase, settingUsecase, taskUsecase, websiteUsecase, config, locale)
if err != nil {
cleanup()
return nil, nil, err
}
logRepo, err := data.NewLogRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := service.NewLogService(logUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
monitorRepo, err := data.NewMonitorRepo(db)
logService := service.NewLogService(logUsecase, locale)
monitorRepo, err := data.NewMonitorRepo()
if err != nil {
cleanup()
return nil, nil, err
}
monitorUsecase := biz.NewMonitorUsecase(monitorRepo, settingRepo)
monitorService, err := service.NewMonitorService(monitorUsecase, settingUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
notifyService, err := service.NewNotifyService(notifyUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
processService, err := service.NewProcessService()
if err != nil {
cleanup()
return nil, nil, err
}
projectService, err := service.NewProjectService(projectUsecase, settingUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
safeRepo, err := data.NewSafeRepo()
if err != nil {
cleanup()
return nil, nil, err
}
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, err := service.NewSafeService(safeUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
settingService, err := service.NewSettingService(certAccountUsecase, certUsecase, settingUsecase, db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
sshRepo, err := data.NewSSHRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
safeService := service.NewSafeService(safeUsecase)
settingService := service.NewSettingService(certAccountUsecase, certUsecase, settingUsecase, db, locale)
sshRepo := data.NewSSHRepo(db, locale)
sshUsecase := biz.NewSSHUsecase(sshRepo, slogLogger)
sshService, err := service.NewSSHService(sshUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
systemctlService, err := service.NewSystemctlService(locale)
if err != nil {
cleanup()
return nil, nil, err
}
tamperService, err := service.NewTamperService(tamperUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
taskService, err := service.NewTaskService(taskUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
templateRepo, err := data.NewTemplateRepo(slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
templateUsecase, err := biz.NewTemplateUsecase(locale, cacheRepo, templateRepo)
if err != nil {
cleanup()
return nil, nil, err
}
templateService, err := service.NewTemplateService(settingUsecase, templateUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
toolboxBenchmarkService, err := service.NewToolboxBenchmarkService(locale)
if err != nil {
cleanup()
return nil, nil, err
}
toolboxDiskService, err := service.NewToolboxDiskService(locale)
if err != nil {
cleanup()
return nil, nil, err
}
toolboxLogService, err := service.NewToolboxLogService(containerImageUsecase, settingUsecase, db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
toolboxMigrationService, err := service.NewToolboxMigrationService(appUsecase, databaseServerUsecase, databaseUsecase, databaseUserUsecase, environmentUsecase, projectUsecase, settingUsecase, websiteUsecase, config, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
toolboxNetworkService, err := service.NewToolboxNetworkService()
if err != nil {
cleanup()
return nil, nil, err
}
toolboxSSHService, err := service.NewToolboxSSHService(locale)
if err != nil {
cleanup()
return nil, nil, err
}
toolboxSystemService, err := service.NewToolboxSystemService(locale)
if err != nil {
cleanup()
return nil, nil, err
}
userUsecase, err := biz.NewUserUsecase(locale, slogLogger, userRepo)
if err != nil {
cleanup()
return nil, nil, err
}
userService, err := service.NewUserService(notifyUsecase, userUsecase, config, locale, manager)
if err != nil {
cleanup()
return nil, nil, err
}
userPasskeyRepo, err := data.NewUserPasskeyRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := service.NewUserPasskeyService(notifyUsecase, userPasskeyUsecase, userUsecase, config, locale, manager)
if err != nil {
cleanup()
return nil, nil, err
}
userPasskeyService := service.NewUserPasskeyService(notifyUsecase, userPasskeyUsecase, userUsecase, config, locale, manager)
userTokenUsecase := biz.NewUserTokenUsecase(userTokenRepo)
userTokenService, err := service.NewUserTokenService(userTokenUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
webHookRepo, err := data.NewWebHookRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
webHookUsecase, err := biz.NewWebHookUsecase(locale, slogLogger, webHookRepo)
if err != nil {
cleanup()
return nil, nil, err
}
webHookService, err := service.NewWebHookService(webHookUsecase)
if err != nil {
cleanup()
return nil, nil, err
}
websiteService, err := service.NewWebsiteService(settingUsecase, websiteUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := service.NewWebsiteStatService(settingUsecase, websiteStatUsecase, websiteUsecase, aggregator)
if err != nil {
cleanup()
return nil, nil, err
}
wafRepo, err := data.NewWafRepo(locale, db, slogLogger, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
wafUsecase := biz.NewWafUsecase(wafRepo)
wafService, err := service.NewWafService(wafUsecase, locale)
if err != nil {
cleanup()
return nil, nil, err
}
wsService, err := service.NewWsService(backupUsecase, certUsecase, sshUsecase, settingUsecase, taskUsecase, config, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
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,
@@ -884,7 +327,6 @@ func initAce() (*app.Ace, func(), error) {
WebHook: webHookService,
Website: websiteService,
WebsiteStat: websiteStatService,
Waf: wafService,
Ws: wsService,
}
v := route.NewEndpoints(services)
@@ -900,6 +342,7 @@ func initAce() (*app.Ace, func(), error) {
Cert: certUsecase,
CertAccount: certAccountUsecase,
FileShare: fileShareUsecase,
Monitor: monitorUsecase,
Notify: notifyUsecase,
ScanEvent: scanEventUsecase,
Setting: settingUsecase,
@@ -919,26 +362,14 @@ func initAce() (*app.Ace, func(), error) {
cleanup()
return nil, nil, err
}
gormigrate, err := bootstrap.NewMigrate(db)
if err != nil {
cleanup()
return nil, nil, err
}
gormigrate := bootstrap.NewMigrate(db)
reloader, err := bootstrap.NewTLSReloader(config)
if err != nil {
cleanup()
return nil, nil, err
}
server, err := bootstrap.NewHttp(mux, config, reloader)
if err != nil {
cleanup()
return nil, nil, err
}
ace, err := app.NewAce(mux, config, cron, gormigrate, server, reloader, taskRunner)
if err != nil {
cleanup()
return nil, nil, err
}
server := bootstrap.NewHttp(mux, config, reloader)
ace := app.NewAce(mux, config, cron, gormigrate, server, reloader, taskRunner)
return ace, func() {
cleanup()
}, nil
+34 -164
View File
@@ -26,10 +26,7 @@ func initCli() (*app.Cli, func(), error) {
if err != nil {
return nil, nil, err
}
locale, err := bootstrap.NewT(config)
if err != nil {
return nil, nil, err
}
locale := bootstrap.NewT(config)
db, err := bootstrap.NewDB(config)
if err != nil {
return nil, nil, err
@@ -39,181 +36,54 @@ func initCli() (*app.Cli, func(), error) {
return nil, nil, err
}
slogLogger := bootstrap.NewSlog(logger)
appRepo, err := data.NewAppRepo(config, db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
cacheRepo, err := data.NewCacheRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
notifyChannelRepo, err := data.NewNotifyChannelRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
settingRepo, err := data.NewSettingRepo(config, db)
if err != nil {
cleanup()
return nil, nil, err
}
notifyUsecase, err := biz.NewNotifyUsecase(locale, slogLogger, notifyChannelRepo, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
taskRunner, err := bootstrap.NewRunner(notifyUsecase, db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
taskRepo, err := data.NewTaskRepo(db, locale, slogLogger, taskRunner)
if err != nil {
cleanup()
return nil, nil, err
}
appUsecase, err := biz.NewAppUsecase(locale, appRepo, cacheRepo, taskRepo)
if err != nil {
cleanup()
return nil, nil, err
}
websiteRepo, err := data.NewWebsiteRepo(db, locale, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
backupRepo, err := data.NewBackupRepo(config, db, locale, slogLogger, settingRepo, websiteRepo)
if err != nil {
cleanup()
return nil, nil, err
}
backupUsecase, err := biz.NewBackupUsecase(notifyUsecase, locale, slogLogger, backupRepo)
if err != nil {
cleanup()
return nil, nil, err
}
appRepo := data.NewAppRepo(config, db, locale, slogLogger)
cacheRepo := data.NewCacheRepo(db)
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, err := data.NewCertAccountRepo(db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
userRepo, err := data.NewUserRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
certAccountUsecase, err := biz.NewCertAccountUsecase(locale, slogLogger, certAccountRepo, userRepo)
if err != nil {
cleanup()
return nil, nil, err
}
certRepo, err := data.NewCertRepo(db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
certUsecase, err := biz.NewCertUsecase(locale, slogLogger, certRepo, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
cronRepo, err := data.NewCronRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := data.NewDatabaseServerRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
databaseServerUsecase, err := biz.NewDatabaseServerUsecase(locale, slogLogger, databaseServerRepo)
if err != nil {
cleanup()
return nil, nil, err
}
settingUsecase, err := biz.NewSettingUsecase(locale, slogLogger, settingRepo, taskRepo)
if err != nil {
cleanup()
return nil, nil, err
}
userPasskeyRepo, err := data.NewUserPasskeyRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := biz.NewUserUsecase(locale, slogLogger, userRepo)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUserRepo, err := data.NewDatabaseUserRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUserUsecase, err := biz.NewDatabaseUserUsecase(slogLogger, databaseServerRepo, databaseUserRepo)
if err != nil {
cleanup()
return nil, nil, err
}
databaseRepo, err := data.NewDatabaseRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUsecase, err := biz.NewDatabaseUsecase(databaseUserUsecase, locale, slogLogger, databaseRepo, databaseServerRepo)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := biz.NewTamperUsecase(notifyUsecase, settingUsecase, locale, slogLogger, tamperRepo)
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, err := biz.NewWebsiteUsecase(certAccountUsecase, certUsecase, databaseUsecase, databaseUserUsecase, tamperUsecase, websiteStatUsecase, locale, slogLogger, databaseServerRepo, websiteRepo)
if err != nil {
cleanup()
return nil, nil, err
}
cliService, err := service.NewCliService(appUsecase, backupUsecase, cacheUsecase, certAccountUsecase, certUsecase, cronUsecase, databaseServerUsecase, notifyUsecase, settingUsecase, userPasskeyUsecase, userUsecase, websiteUsecase, config, db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
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, err := bootstrap.NewCli(locale, v)
if err != nil {
cleanup()
return nil, nil, err
}
gormigrate, err := bootstrap.NewMigrate(db)
if err != nil {
cleanup()
return nil, nil, err
}
cli, err := app.NewCli(cliCommand, gormigrate)
if err != nil {
cleanup()
return nil, nil, err
}
cliCommand := bootstrap.NewCli(locale, v)
gormigrate := bootstrap.NewMigrate(db)
cli := app.NewCli(cliCommand, gormigrate)
return cli, func() {
cleanup()
}, nil
+15 -12
View File
@@ -12,6 +12,7 @@ require (
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/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.1
@@ -23,6 +24,7 @@ require (
github.com/google/wire v0.7.0
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.12.3
github.com/libdns/alidns v1.0.7
@@ -40,7 +42,7 @@ require (
github.com/libtnb/gormstore v1.3.0
github.com/libtnb/logrotate v0.1.2
github.com/libtnb/sessions v1.5.0
github.com/libtnb/sqlite v1.2.1
github.com/libtnb/sqlite v1.2.2
github.com/libtnb/utils v1.2.2
github.com/libtnb/validator v0.4.1
github.com/libtnb/validator/contrib/openapi v0.2.0
@@ -49,11 +51,12 @@ require (
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/pelletier/go-toml/v2 v2.4.3
github.com/pkg/sftp v1.13.11
github.com/pquerna/otp v1.5.0
github.com/samber/lo v1.53.0
github.com/sethvargo/go-limiter v1.2.0
github.com/shirou/gopsutil/v4 v4.26.6
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.13.0
@@ -63,12 +66,13 @@ require (
github.com/wneessen/go-mail v0.8.1
github.com/xuri/excelize/v2 v2.11.0
go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
golang.org/x/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.55.0
modernc.org/sqlite v1.56.0
resty.dev/v3 v3.0.0-rc.3
)
@@ -80,11 +84,10 @@ require (
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.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@@ -106,7 +109,7 @@ require (
github.com/kr/fs v0.1.0 // indirect
github.com/libtnb/securecookie v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -135,10 +138,10 @@ require (
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
modernc.org/libc v1.74.1 // indirect
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
)
+30 -26
View File
@@ -77,8 +77,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
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=
@@ -156,8 +156,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/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=
@@ -223,6 +223,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.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=
@@ -276,8 +278,8 @@ github.com/libtnb/securecookie v1.4.0 h1:SkKHO7T5I4aRGV7/6fnYYsleQDnnDzeAmTDA0GM
github.com/libtnb/securecookie v1.4.0/go.mod h1:mg1i9HfstsYBGwCfQdU+3Z1GuieyZRAxbkFUnrzchJU=
github.com/libtnb/sessions v1.5.0 h1:gvTRkSR8lpp1PB0Nms/kc+gh1BwJ411fxLjnZLYItLs=
github.com/libtnb/sessions v1.5.0/go.mod h1:/Q/+lO7DcnJJEU1nL59ScPV/V8ct34F8BjoEMwwxjRw=
github.com/libtnb/sqlite v1.2.1 h1:lrhN3yG49pj1gmtMMP6Es0lhKIkzDeqBul2EWOLyjpY=
github.com/libtnb/sqlite v1.2.1/go.mod h1:pGq13BirCjkM6ihkWWxs0PGFhBiwUyA0PqdSmgDWfmM=
github.com/libtnb/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=
@@ -291,8 +293,8 @@ github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIv
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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
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=
@@ -333,6 +335,8 @@ 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=
@@ -376,8 +380,8 @@ github.com/sethvargo/go-limiter v1.2.0 h1:XKL1vsaQ2zztVJrnZSzpRWCq/aLQqMllJ/3D/0
github.com/sethvargo/go-limiter v1.2.0/go.mod h1:RC+qY2R7PAK81mBCrZEJlUlKnXSIqqQ8B7G44UgZ/1E=
github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8=
github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/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=
@@ -470,8 +474,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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=
@@ -494,8 +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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
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=
@@ -510,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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
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=
@@ -546,8 +550,8 @@ 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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -567,8 +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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
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=
@@ -612,8 +616,8 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
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.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
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=
@@ -624,8 +628,8 @@ 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.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
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=
@@ -634,8 +638,8 @@ 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.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.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=
+2 -2
View File
@@ -30,7 +30,7 @@ type Ace struct {
runner types.TaskRunner
}
func NewAce(router *chi.Mux, conf *config.Config, cron *cron.Cron, migrator *gormigrate.Gormigrate, server *hlfhr.Server, reloader *tlscert.Reloader, runner types.TaskRunner) (*Ace, error) {
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,
@@ -39,7 +39,7 @@ func NewAce(router *chi.Mux, conf *config.Config, cron *cron.Cron, migrator *gor
migrator: migrator,
cron: cron,
runner: runner,
}, nil
}
}
func (r *Ace) Run() error {
+2 -2
View File
@@ -13,12 +13,12 @@ type Cli struct {
migrator *gormigrate.Gormigrate
}
func NewCli(cmd *cli.Command, migrator *gormigrate.Gormigrate) (*Cli, error) {
func NewCli(cmd *cli.Command, migrator *gormigrate.Gormigrate) *Cli {
IsCli = true
return &Cli{
cmd: cmd,
migrator: migrator,
}, nil
}
}
func (r *Cli) Run() error {
-23
View File
@@ -1,23 +0,0 @@
package acewaf
import (
"github.com/go-chi/chi/v5"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct{}
func NewApp() (*App, error) {
return &App{}, nil
}
func (s *App) Route(_ chi.Router) {
// WAF 管理路由由 route 贡献(route/waf.go)统一提供
}
func (s *App) Status() string {
ok, _ := systemctl.Status("acewaf")
return types.AggregateAppStatus(ok)
}
+89 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/spf13/cast"
"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"
@@ -18,14 +19,16 @@ import (
"github.com/acepanel/panel/v3/pkg/types"
)
var mpmEventRegexp = regexp.MustCompile(`(?s)<IfModule mpm_event_module>(.*?)</IfModule>`)
type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}, nil
}
}
func (s *App) Route(r chi.Router) {
@@ -34,6 +37,8 @@ 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 {
@@ -42,7 +47,7 @@ func (s *App) Status() string {
}
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
@@ -58,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
}
@@ -160,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"`
}
-2
View File
@@ -3,7 +3,6 @@ package apps
import (
"github.com/google/wire"
"github.com/acepanel/panel/v3/internal/apps/acewaf"
"github.com/acepanel/panel/v3/internal/apps/apache"
"github.com/acepanel/panel/v3/internal/apps/clickhouse"
"github.com/acepanel/panel/v3/internal/apps/codeserver"
@@ -48,5 +47,4 @@ var ProviderSet = wire.NewSet(
postgresql.NewApp, prometheus.NewApp, pureftpd.NewApp,
redis.NewApp, rocketmq.NewApp, rsync.NewApp,
s3fs.NewApp, supervisor.NewApp, valkey.NewApp,
acewaf.NewApp,
)
+19 -56
View File
@@ -2,6 +2,7 @@ package clickhouse
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
@@ -9,11 +10,12 @@ import (
"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/biz"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
@@ -27,12 +29,12 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) (*App, error) {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) *App {
return &App{
t: t,
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
}, nil
}
}
func (s *App) Route(r chi.Router) {
@@ -106,29 +108,12 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
conf, _ := io.Read(s.configPath())
service.Success(w, conf)
common.ServeConfig(w, s.configPath())
}
// 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(s.configPath(), req.Config, 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)
common.SaveConfig(w, r, s.configPath(), "clickhouse-server")
}
// GetConfigTune 获取配置调整参数
@@ -141,14 +126,14 @@ func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
}
tune := ConfigTune{
ListenHost: s.getYAMLValue(cfg, "listen_host"),
HTTPPort: s.getYAMLValue(cfg, "http_port"),
TCPPort: s.getYAMLValue(cfg, "tcp_port"),
MaxMemoryUsage: s.getYAMLValue(cfg, "max_memory_usage"),
MaxThreads: s.getYAMLValue(cfg, "max_threads"),
Path: s.getYAMLValue(cfg, "path"),
TmpPath: s.getYAMLValue(cfg, "tmp_path"),
LogLevel: s.getYAMLValue(cfg, "logger.level"),
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)
@@ -218,7 +203,7 @@ func (s *App) SetDefaultPassword(w http.ResponseWriter, r *http.Request) {
// 计算 SHA256 哈希
hash := sha256.Sum256([]byte(req.Password))
hexHash := fmt.Sprintf("%x", hash)
hexHash := hex.EncodeToString(hash[:])
// 读取 users.d/default.yaml 并更新密码
raw, _ := io.Read(s.usersConfigPath())
@@ -262,12 +247,12 @@ func (s *App) SetDefaultPassword(w http.ResponseWriter, r *http.Request) {
// configPath 返回主配置文件路径
func (s *App) configPath() string {
return fmt.Sprintf("%s/server/clickhouse/config/config.yaml", app.Root)
return app.Root + "/server/clickhouse/config/config.yaml"
}
// usersConfigPath 返回用户密码配置文件路径(users.d/ 由 ConfigProcessor 自动合并到 users.yaml
func (s *App) usersConfigPath() string {
return fmt.Sprintf("%s/server/clickhouse/config/users.d/default.yaml", app.Root)
return app.Root + "/server/clickhouse/config/users.d/default.yaml"
}
// getPort 从配置中获取 HTTP 端口
@@ -276,35 +261,13 @@ func (s *App) getPort() string {
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg != nil {
if v := s.getYAMLValue(cfg, "http_port"); v != "" {
if v := confval.GetYAML(cfg, "http_port"); v != "" {
return v
}
}
return "8123"
}
// getYAMLValue 获取 YAML 值,支持嵌套键
func (s *App) getYAMLValue(cfg map[string]any, key string) string {
// 先尝试平铺键
if val, ok := cfg[key]; ok {
return cast.ToString(val)
}
// 回退到嵌套键
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])
}
// setYAMLValue 设置平铺 YAML 值
func (s *App) setYAMLValue(cfg map[string]any, key string, value string) {
if value == "" {
-5
View File
@@ -1,10 +1,5 @@
package clickhouse
// UpdateConfig 更新配置
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// SetDefaultPassword 设置 default 用户密码
type SetDefaultPassword struct {
Password string `form:"password" json:"password" validate:"required && password"`
+2 -2
View File
@@ -13,8 +13,8 @@ import (
type App struct{}
func NewApp() (*App, error) {
return &App{}, nil
func NewApp() *App {
return &App{}
}
func (s *App) Route(r chi.Router) {
+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)
}
}
+5 -26
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"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"
@@ -16,8 +17,8 @@ import (
type App struct{}
func NewApp() (*App, error) {
return &App{}, nil
func NewApp() *App {
return &App{}
}
func (s *App) Route(r chi.Router) {
@@ -33,33 +34,11 @@ func (s *App) Status() string {
}
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
}
service.Success(w, config)
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 设置
-4
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"
+24 -80
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -15,6 +14,8 @@ import (
"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"
@@ -25,8 +26,8 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
return &App{t: t}, nil
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
@@ -92,28 +93,11 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
conf, _ := io.Read(s.configPath())
service.Success(w, conf)
common.ServeConfig(w, s.configPath())
}
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 = systemctl.Restart("elasticsearch"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
common.SaveConfig(w, r, s.configPath(), "elasticsearch")
}
// GetConfigTune 获取 ElasticSearch 配置调整参数
@@ -135,13 +119,13 @@ func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
}
tune := ConfigTune{
ClusterName: s.getYAMLValue(cfg, "cluster.name"),
NodeName: s.getYAMLValue(cfg, "node.name"),
NetworkHost: s.getYAMLValue(cfg, "network.host"),
HTTPPort: s.getYAMLValue(cfg, "http.port"),
DiscoveryType: s.getYAMLValue(cfg, "discovery.type"),
PathData: s.getYAMLValue(cfg, "path.data"),
PathLogs: s.getYAMLValue(cfg, "path.logs"),
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,
}
@@ -164,13 +148,13 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
cfg = make(map[string]any)
}
s.setYAMLValue(cfg, "cluster.name", req.ClusterName)
s.setYAMLValue(cfg, "node.name", req.NodeName)
s.setYAMLValue(cfg, "network.host", req.NetworkHost)
s.setYAMLValue(cfg, "http.port", req.HTTPPort)
s.setYAMLValue(cfg, "discovery.type", req.DiscoveryType)
s.setYAMLValue(cfg, "path.data", req.PathData)
s.setYAMLValue(cfg, "path.logs", req.PathLogs)
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 {
@@ -202,17 +186,17 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
// configPath 返回配置文件路径
func (s *App) configPath() string {
return fmt.Sprintf("%s/server/elasticsearch/config/elasticsearch.yml", app.Root)
return app.Root + "/server/elasticsearch/config/elasticsearch.yml"
}
// jvmOptionsPath 返回 JVM 选项文件路径
func (s *App) jvmOptionsPath() string {
return fmt.Sprintf("%s/server/elasticsearch/config/jvm.options", app.Root)
return app.Root + "/server/elasticsearch/config/jvm.options"
}
// jvmHeapOptionsPath 返回 JVM 堆内存配置文件路径(ES 9.x 推荐方式)
func (s *App) jvmHeapOptionsPath() string {
return fmt.Sprintf("%s/server/elasticsearch/config/jvm.options.d/heap.options", app.Root)
return app.Root + "/server/elasticsearch/config/jvm.options.d/heap.options"
}
// getPort 从配置中获取 HTTP 端口
@@ -221,53 +205,13 @@ func (s *App) getPort() string {
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg != nil {
if v := s.getYAMLValue(cfg, "http.port"); v != "" {
if v := confval.GetYAML(cfg, "http.port"); v != "" {
return v
}
}
return "9200"
}
// getYAMLValue 获取 YAML 值,优先匹配平铺键(如 "path.data"),回退到嵌套键(如 path -> data
func (s *App) getYAMLValue(cfg map[string]any, key string) string {
// 优先匹配平铺键(安装脚本用 sed 生成的格式)
if val, ok := cfg[key]; ok {
return cast.ToString(val)
}
// 回退到嵌套键
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])
}
// setYAMLValue 设置 YAML 值
func (s *App) setYAMLValue(cfg map[string]any, key string, value string) {
if value == "" {
return
}
// 使用平铺键,同时清理可能存在的嵌套键
cfg[key] = value
parts := strings.SplitN(key, ".", 2)
if len(parts) == 2 {
if nested, ok := cfg[parts[0]].(map[string]any); ok {
delete(nested, parts[1])
if len(nested) == 0 {
delete(cfg, parts[0])
}
}
}
}
// parseJVMHeap 从 jvm.options 中提取堆内存配置
func (s *App) parseJVMHeap(content string) (initSize, maxSize string) {
reInit := regexp.MustCompile(`(?m)^-Xms(\S+)`)
-4
View File
@@ -1,9 +1,5 @@
package elasticsearch
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// ConfigTune ElasticSearch 配置调整
type ConfigTune struct {
// 集群
+126 -185
View File
@@ -1,25 +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/v2"
"github.com/libtnb/utils/str"
"github.com/samber/lo"
"github.com/spf13/cast"
"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 {
@@ -27,18 +27,19 @@ type App struct {
websiteRepo biz.WebsiteRepo
}
func NewApp(t *gotext.Locale, websiteRepo biz.WebsiteRepo) (*App, error) {
func NewApp(t *gotext.Locale, websiteRepo biz.WebsiteRepo) *App {
return &App{
t: t,
websiteRepo: websiteRepo,
}, nil
}
}
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)
@@ -51,39 +52,12 @@ func (s *App) Status() string {
// 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{
@@ -99,82 +73,36 @@ 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}' | paste -sd ','")
@@ -193,99 +121,130 @@ 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, " ")
list := lo.FilterMap(bannedIpList, func(ip string, _ int) (map[string]string, bool) {
if len(ip) == 0 {
return nil, false
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
}
}
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,
}, true
}
})
service.Success(w, chix.M{
@@ -319,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)
}
+11 -6
View File
@@ -1,7 +1,7 @@
package fail2ban
type Add struct {
Name string `json:"name" validate:"required"`
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"`
@@ -11,16 +11,21 @@ type Add struct {
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"`
Name string `form:"name" json:"name" validate:"required"`
IP string `json:"ip" validate:"required && ip"`
}
+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"`
}
+304 -14
View File
@@ -5,18 +5,23 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix/v2"
"github.com/acepanel/panel/v3/internal/app"
"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, error) {
return &App{}, nil
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}
}
func (s *App) Route(r chi.Router) {
@@ -24,6 +29,20 @@ 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 {
@@ -39,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
@@ -55,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
}
@@ -114,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))
}
@@ -146,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)
}
+5
View File
@@ -14,3 +14,8 @@ type UpdateUser struct {
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"`
}
+5 -24
View File
@@ -1,22 +1,20 @@
package gitea
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"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{}
func NewApp() (*App, error) {
return &App{}, nil
func NewApp() *App {
return &App{}
}
func (s *App) Route(r chi.Router) {
@@ -30,26 +28,9 @@ func (s *App) Status() string {
}
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"`
}
+30 -150
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -14,6 +13,8 @@ import (
"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"
@@ -24,8 +25,8 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
return &App{t: t}, nil
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
@@ -59,7 +60,7 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
// 从 defaults.ini 获取端口
config, _ := io.Read(s.configPath())
port := s.getINIValue(config, "server", "http_port")
port := confval.SectionINI.GetIn(config, "server", "http_port")
if port == "" {
port = "3000"
}
@@ -92,29 +93,11 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(s.configPath())
service.Success(w, config)
common.ServeConfig(w, s.configPath())
}
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 = systemctl.Restart("grafana"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
common.SaveConfig(w, r, s.configPath(), "grafana")
}
// GetConfigTune 获取 Grafana 配置调整参数
@@ -122,7 +105,7 @@ func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(s.configPath())
get := func(section, key string) string {
return s.getINIValue(config, section, key)
return confval.SectionINI.GetIn(config, section, key)
}
tune := ConfigTune{
@@ -168,31 +151,31 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(s.configPath())
// [server]
config = s.setINIValue(config, "server", "http_port", req.HTTPPort)
config = s.setINIValue(config, "server", "domain", req.Domain)
config = s.setINIValue(config, "server", "root_url", req.RootURL)
config = s.setINIValue(config, "server", "protocol", req.Protocol)
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 = s.setINIValue(config, "database", "type", req.DBType)
config = s.setINIValue(config, "database", "host", req.DBHost)
config = s.setINIValue(config, "database", "name", req.DBName)
config = s.setINIValue(config, "database", "user", req.DBUser)
config = s.setINIValue(config, "database", "password", req.DBPassword)
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 = s.setINIValue(config, "security", "admin_user", req.AdminUser)
config = s.setINIValue(config, "security", "admin_password", req.AdminPassword)
config = confval.SectionINI.SetIn(config, "security", "admin_user", req.AdminUser)
config = confval.SectionINI.SetIn(config, "security", "admin_password", req.AdminPassword)
// [users]
config = s.setINIValue(config, "users", "allow_sign_up", req.AllowSignUp)
config = s.setINIValue(config, "users", "auto_assign_org_role", req.AutoAssignOrgRole)
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 = s.setINIValue(config, "smtp", "enabled", req.SMTPEnabled)
config = s.setINIValue(config, "smtp", "host", req.SMTPHost)
config = s.setINIValue(config, "smtp", "user", req.SMTPUser)
config = s.setINIValue(config, "smtp", "password", req.SMTPPassword)
config = s.setINIValue(config, "smtp", "from_address", req.SMTPFromAddress)
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 = s.setINIValue(config, "log", "mode", req.LogMode)
config = s.setINIValue(config, "log", "level", req.LogLevel)
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)
@@ -328,115 +311,12 @@ func (s *App) DeleteDataSource(w http.ResponseWriter, r *http.Request) {
// configPath 返回 Grafana 主配置文件路径
func (s *App) configPath() string {
return fmt.Sprintf("%s/server/grafana/conf/defaults.ini", app.Root)
}
// getINIValue 从 INI 配置中获取指定 section 下的 key 值
func (s *App) getINIValue(content string, section string, key string) string {
currentSection := ""
for line := range strings.SplitSeq(content, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") {
continue
}
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
currentSection = strings.TrimSpace(trimmed[1 : len(trimmed)-1])
continue
}
if currentSection != section {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) == 2 && strings.TrimSpace(parts[0]) == key {
return strings.TrimSpace(parts[1])
}
}
return ""
}
// setINIValue 在 INI 配置中设置指定 section 下的 key 值
func (s *App) setINIValue(content string, section string, key string, value string) string {
lines := strings.Split(content, "\n")
result := make([]string, 0, len(lines))
currentSection := ""
found := false
lastSectionLine := -1 // 目标 section 的最后一行索引
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// 检测 section 头
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
// 如果离开目标 section 且未找到 key,在 section 末尾插入
if currentSection == section && !found && lastSectionLine >= 0 {
found = true
if value != "" {
// 在 section 末尾插入新行
insertIdx := lastSectionLine + 1
newLine := key + " = " + value
result = append(result[:insertIdx+1], append([]string{newLine}, result[insertIdx+1:]...)...)
}
}
currentSection = strings.TrimSpace(trimmed[1 : len(trimmed)-1])
}
if currentSection == section {
lastSectionLine = len(result)
}
// 在目标 section 内匹配 key
if currentSection == section && !found {
checkLine := trimmed
commented := false
if strings.HasPrefix(checkLine, ";") {
checkLine = strings.TrimSpace(checkLine[1:])
commented = true
} else if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
commented = true
}
parts := strings.SplitN(checkLine, "=", 2)
if len(parts) == 2 && strings.TrimSpace(parts[0]) == key {
found = true
if value == "" {
// 值为空时注释掉
if !commented {
result = append(result, ";"+line)
} else {
result = append(result, line)
}
} else {
result = append(result, key+" = "+value)
}
_ = i
continue
}
}
result = append(result, line)
}
// 如果在最后一个 section 中未找到 key
if currentSection == section && !found {
found = true
if value != "" {
result = append(result, key+" = "+value)
}
}
// section 不存在,在文件末尾追加
if !found && value != "" {
result = append(result, "")
result = append(result, "["+section+"]")
result = append(result, key+" = "+value)
}
return strings.Join(result, "\n")
return app.Root + "/server/grafana/conf/defaults.ini"
}
// datasourcePath 返回 provisioning 数据源文件路径
func (s *App) datasourcePath() string {
return fmt.Sprintf("%s/server/grafana/conf/provisioning/datasources/panel.yml", app.Root)
return app.Root + "/server/grafana/conf/provisioning/datasources/panel.yml"
}
// readDatasources 读取 provisioning 文件
-4
View File
@@ -1,9 +1,5 @@
package grafana
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// ConfigTune Grafana 配置调整
type ConfigTune struct {
// [server]
+26 -100
View File
@@ -4,12 +4,13 @@ import (
"fmt"
"net/http"
"regexp"
"strings"
"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"
@@ -20,8 +21,8 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
return &App{t: t}, nil
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
@@ -51,40 +52,23 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(s.configPath())
data := []types.NV{
{Name: s.t.Get("Node ID"), Value: s.getPropertiesValue(config, "node.id")},
{Name: s.t.Get("Listeners"), Value: s.getPropertiesValue(config, "listeners")},
{Name: s.t.Get("Log Dirs"), Value: s.getPropertiesValue(config, "log.dirs")},
{Name: s.t.Get("Num Partitions"), Value: s.getPropertiesValue(config, "num.partitions")},
{Name: s.t.Get("Log Retention Hours"), Value: s.getPropertiesValue(config, "log.retention.hours")},
{Name: s.t.Get("Log Segment Bytes"), Value: s.getPropertiesValue(config, "log.segment.bytes")},
{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) {
conf, _ := io.Read(s.configPath())
service.Success(w, conf)
common.ServeConfig(w, s.configPath())
}
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 = systemctl.Restart("kafka"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
common.SaveConfig(w, r, s.configPath(), "kafka")
}
// GetConfigTune 获取 Kafka 配置调整参数
@@ -95,12 +79,12 @@ func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
heapInit, heapMax := s.parseHeapEnv(heapRaw)
tune := ConfigTune{
NodeID: s.getPropertiesValue(config, "node.id"),
Listeners: s.getPropertiesValue(config, "listeners"),
LogDirs: s.getPropertiesValue(config, "log.dirs"),
NumPartitions: s.getPropertiesValue(config, "num.partitions"),
RetentionHours: s.getPropertiesValue(config, "log.retention.hours"),
LogSegmentBytes: s.getPropertiesValue(config, "log.segment.bytes"),
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,
}
@@ -118,12 +102,12 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(s.configPath())
config = s.setPropertiesValue(config, "node.id", req.NodeID)
config = s.setPropertiesValue(config, "listeners", req.Listeners)
config = s.setPropertiesValue(config, "log.dirs", req.LogDirs)
config = s.setPropertiesValue(config, "num.partitions", req.NumPartitions)
config = s.setPropertiesValue(config, "log.retention.hours", req.RetentionHours)
config = s.setPropertiesValue(config, "log.segment.bytes", req.LogSegmentBytes)
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)
@@ -150,70 +134,12 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
// configPath 返回配置文件路径
func (s *App) configPath() string {
return fmt.Sprintf("%s/server/kafka/config/server.properties", app.Root)
return app.Root + "/server/kafka/config/server.properties"
}
// heapEnvPath 返回 JVM 堆内存配置文件路径
func (s *App) heapEnvPath() string {
return fmt.Sprintf("%s/server/kafka/config/heap.env", app.Root)
}
// getPropertiesValue 从 properties 内容中获取指定键的值
func (s *App) getPropertiesValue(content string, key string) string {
for line := range strings.SplitSeq(content, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
k, v, ok := strings.Cut(trimmed, "=")
if ok && strings.TrimSpace(k) == key {
return strings.TrimSpace(v)
}
}
return ""
}
// setPropertiesValue 在 properties 内容中设置指定键的值
func (s *App) setPropertiesValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
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)
continue
}
checkLine := trimmed
if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
}
k, _, ok := strings.Cut(checkLine, "=")
if ok && strings.TrimSpace(k) == 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 !found && value != "" {
result = append(result, key+"="+value)
}
return strings.Join(result, "\n")
return app.Root + "/server/kafka/config/heap.env"
}
// parseHeapEnv 从 heap.env 中提取堆内存配置
-5
View File
@@ -1,10 +1,5 @@
package kafka
// UpdateConfig Kafka 配置更新
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// ConfigTune Kafka 配置调整
type ConfigTune struct {
// Broker
+2 -2
View File
@@ -10,10 +10,10 @@ type App struct {
mysql *mysql.App
}
func NewApp(mysqlApp *mysql.App) (*App, error) {
func NewApp(mysqlApp *mysql.App) *App {
return &App{
mysql: mysqlApp,
}, nil
}
}
func (s *App) Route(r chi.Router) {
+5 -26
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"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"
@@ -20,10 +21,10 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}, nil
}
}
func (s *App) Route(r chi.Router) {
@@ -90,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 配置调整参数
-4
View File
@@ -1,9 +1,5 @@
package memcached
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// ConfigTune Memcached 配置调整
type ConfigTune struct {
Port string `form:"port" json:"port" validate:"number && min:1 && max:65535"`
+2 -2
View File
@@ -13,8 +13,8 @@ import (
type App struct{}
func NewApp() (*App, error) {
return &App{}, nil
func NewApp() *App {
return &App{}
}
func (s *App) Route(r chi.Router) {
+6 -23
View File
@@ -2,7 +2,6 @@ package mongodb
import (
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -12,6 +11,7 @@ import (
"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"
@@ -26,12 +26,12 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) (*App, error) {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) *App {
return &App{
t: t,
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
}, nil
}
}
func (s *App) Route(r chi.Router) {
@@ -107,29 +107,12 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
conf, _ := io.Read(s.configPath())
service.Success(w, conf)
common.ServeConfig(w, s.configPath())
}
// 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(s.configPath(), req.Config, 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)
common.SaveConfig(w, r, s.configPath(), "mongod")
}
// GetConfigTune 获取配置调整参数
@@ -240,7 +223,7 @@ func (s *App) SetAdminPassword(w http.ResponseWriter, r *http.Request) {
// configPath 返回配置文件路径
func (s *App) configPath() string {
return fmt.Sprintf("%s/server/mongodb/mongod.conf", app.Root)
return app.Root + "/server/mongodb/mongod.conf"
}
// getYAMLValue 获取嵌套 YAML 值,支持 dot notation
-5
View File
@@ -1,10 +1,5 @@
package mongodb
// UpdateConfig 更新配置
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// SetAdminPassword 设置 admin 密码
type SetAdminPassword struct {
Password string `form:"password" json:"password" validate:"required && password"`
+575 -167
View File
@@ -1,10 +1,12 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
"regexp"
"slices"
"strings"
"github.com/go-chi/chi/v5"
@@ -12,6 +14,7 @@ import (
"github.com/spf13/cast"
"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"
@@ -26,14 +29,16 @@ type App struct {
t *gotext.Locale
settingRepo biz.SettingRepo
databaseServerRepo biz.DatabaseServerRepo
taskRepo biz.TaskRepo
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) (*App, error) {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo, taskRepo biz.TaskRepo) *App {
return &App{
t: t,
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
}, nil
taskRepo: taskRepo,
}
}
func (s *App) Route(r chi.Router) {
@@ -45,6 +50,19 @@ func (s *App) Route(r chi.Router) {
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("/tables", s.TableList)
r.Post("/maintenance", s.RunMaintenance)
r.Get("/binlogs", s.BinlogList)
r.Post("/binlogs/purge", s.PurgeBinlog)
r.Get("/replication", s.ReplicationStatus)
}
func (s *App) Status() string {
@@ -52,38 +70,6 @@ func (s *App) Status() string {
return types.AggregateAppStatus(ok)
}
// 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)
}
// Load 获取负载
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, _ := systemctl.Status("mysqld")
@@ -98,19 +84,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 {
@@ -197,9 +175,41 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
service.Success(w, load)
}
// 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))
service.Success(w, app.Root+"/server/mysql/mysql-slow.log")
}
// GetRootPassword 获取root密码
@@ -222,10 +232,10 @@ func (s *App) SetRootPassword(w http.ResponseWriter, r *http.Request) {
}
oldRootPassword, _ := s.settingRepo.Get(biz.SettingKeyMySQLRootPassword)
mysql, err := db.NewMySQL(r.Context(), "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
}
@@ -257,35 +267,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)
@@ -307,35 +317,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)
@@ -345,83 +355,481 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
func (s *App) getSock() string {
if sock := db.MySQLSocket(app.Root+"/server/mysql/config/my.cnf", "/etc/my.cnf"); sock != "" {
return sock
// 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 "/tmp/mysql.sock"
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)
}
// getINIValue 从 INI 格式内容中获取指定键的值
func (s *App) getINIValue(content string, key string) string {
lines := strings.SplitSeq(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])
}
// 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
}
return ""
mysql, err := s.connect(r.Context())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
defer mysql.Close()
// KILL 不支持预编译参数,id 已校验为正整数
if _, err = mysql.Exec(fmt.Sprintf(`KILL %d`, id)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// setINIValue 在 INI 格式内容中设置指定键的值
func (s *App) setINIValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
// 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()
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
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
}
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
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
}
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)
result.LockWaits = append(result.LockWaits, item)
}
}
if !found && value != "" {
result = append(result, key+" = "+value)
}
return strings.Join(result, "\n")
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, round(SUM_TIMER_WAIT/1e9),
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)
}
// TableList 获取表维护信息
func (s *App) TableList(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 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'
ORDER BY data_length + index_length DESC LIMIT 50`)
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))
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 !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
}
escaped := strings.ReplaceAll(rootPassword, `'`, `'\''`)
cmd := fmt.Sprintf("MYSQL_PWD='%s' mysql -u root -e '%s TABLE `%s`.`%s`'", escaped, strings.ToUpper(req.Operation), req.Database, req.Table)
task := new(biz.Task)
task.Key = fmt.Sprintf("mysql:maintenance:%s.%s", req.Database, req.Table)
task.Name = s.t.Get("Run %s on table %s.%s", req.Operation, req.Database, req.Table)
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))})
}
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()
}
+101
View File
@@ -41,3 +41,104 @@ type ConfigTune struct {
SlowQueryLog string `form:"slow_query_log" json:"slow_query_log"`
LongQueryTime string `form:"long_query_time" json:"long_query_time"`
}
// MaintenanceRun 表维护操作请求
type MaintenanceRun struct {
Database string `form:"database" json:"database" validate:"required"`
Table string `form:"table" json:"table" validate:"required"`
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"`
FragmentRate float64 `json:"fragment_rate"`
}
// BinlogFile binlog 文件信息
type BinlogFile struct {
Name string `json:"name"`
Size string `json:"size"`
}
// 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"`
}
+55 -132
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -13,6 +12,7 @@ import (
"resty.dev/v3"
"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"
@@ -25,11 +25,11 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}, nil
}
}
func (s *App) Route(r chi.Router) {
@@ -57,7 +57,7 @@ func (s *App) Status() string {
}
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
@@ -73,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
}
@@ -178,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
@@ -186,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)
@@ -226,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)
@@ -234,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)
@@ -275,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.SplitSeq(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")
}
+10 -9
View File
@@ -1,6 +1,7 @@
package nginx
import (
"errors"
"fmt"
"net/http"
"os"
@@ -36,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
@@ -70,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
@@ -78,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
@@ -110,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
@@ -411,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{
@@ -439,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 块中的指令
+2 -2
View File
@@ -12,10 +12,10 @@ type App struct {
nginx *nginx.App
}
func NewApp(nginxApp *nginx.App) (*App, error) {
func NewApp(nginxApp *nginx.App) *App {
return &App{
nginx: nginxApp,
}, nil
}
}
func (s *App) Route(r chi.Router) {
+23 -79
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -15,6 +14,8 @@ import (
"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"
@@ -25,9 +26,9 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
func NewApp(t *gotext.Locale) *App {
return &App{t: t}, nil
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
@@ -93,28 +94,11 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
conf, _ := io.Read(s.configPath())
service.Success(w, conf)
common.ServeConfig(w, s.configPath())
}
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 = systemctl.Restart("opensearch"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
common.SaveConfig(w, r, s.configPath(), "opensearch")
}
// GetConfigTune 获取 OpenSearch 配置调整参数
@@ -130,13 +114,13 @@ func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
heapInit, heapMax := s.parseJVMHeap(jvmRaw)
tune := ConfigTune{
ClusterName: s.getYAMLValue(cfg, "cluster.name"),
NodeName: s.getYAMLValue(cfg, "node.name"),
NetworkHost: s.getYAMLValue(cfg, "network.host"),
HTTPPort: s.getYAMLValue(cfg, "http.port"),
DiscoveryType: s.getYAMLValue(cfg, "discovery.type"),
PathData: s.getYAMLValue(cfg, "path.data"),
PathLogs: s.getYAMLValue(cfg, "path.logs"),
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,
}
@@ -158,13 +142,13 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
cfg = make(map[string]any)
}
s.setYAMLValue(cfg, "cluster.name", req.ClusterName)
s.setYAMLValue(cfg, "node.name", req.NodeName)
s.setYAMLValue(cfg, "network.host", req.NetworkHost)
s.setYAMLValue(cfg, "http.port", req.HTTPPort)
s.setYAMLValue(cfg, "discovery.type", req.DiscoveryType)
s.setYAMLValue(cfg, "path.data", req.PathData)
s.setYAMLValue(cfg, "path.logs", req.PathLogs)
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 {
@@ -194,11 +178,11 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
}
func (s *App) configPath() string {
return fmt.Sprintf("%s/server/opensearch/config/opensearch.yml", app.Root)
return app.Root + "/server/opensearch/config/opensearch.yml"
}
func (s *App) jvmOptionsPath() string {
return fmt.Sprintf("%s/server/opensearch/config/jvm.options", app.Root)
return app.Root + "/server/opensearch/config/jvm.options"
}
func (s *App) getPort() string {
@@ -206,53 +190,13 @@ func (s *App) getPort() string {
var cfg map[string]any
_ = yaml.Unmarshal([]byte(raw), &cfg)
if cfg != nil {
if v := s.getYAMLValue(cfg, "http.port"); v != "" {
if v := confval.GetYAML(cfg, "http.port"); v != "" {
return v
}
}
return "9200"
}
// getYAMLValue 获取 YAML 值,优先匹配平铺键(如 "path.data"),回退到嵌套键(如 path -> data
func (s *App) getYAMLValue(cfg map[string]any, key string) string {
// 优先匹配平铺键(安装脚本用 sed 生成的格式)
if val, ok := cfg[key]; ok {
return cast.ToString(val)
}
// 回退到嵌套键
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])
}
// setYAMLValue 设置 YAML 值
func (s *App) setYAMLValue(cfg map[string]any, key string, value string) {
if value == "" {
return
}
// 使用平铺键,同时清理可能存在的嵌套键
cfg[key] = value
parts := strings.SplitN(key, ".", 2)
if len(parts) == 2 {
if nested, ok := cfg[parts[0]].(map[string]any); ok {
delete(nested, parts[1])
if len(nested) == 0 {
delete(cfg, parts[0])
}
}
}
}
func (s *App) parseJVMHeap(content string) (initSize, maxSize string) {
reInit := regexp.MustCompile(`(?m)^-Xms(\S+)`)
reMax := regexp.MustCompile(`(?m)^-Xmx(\S+)`)
-4
View File
@@ -1,9 +1,5 @@
package opensearch
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// ConfigTune OpenSearch 配置调整
type ConfigTune struct {
// 集群
+2 -2
View File
@@ -10,10 +10,10 @@ type App struct {
mysql *mysql.App
}
func NewApp(mysqlApp *mysql.App) (*App, error) {
func NewApp(mysqlApp *mysql.App) *App {
return &App{
mysql: mysqlApp,
}, nil
}
}
func (s *App) Route(r chi.Router) {
+8 -8
View File
@@ -38,12 +38,12 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(conf *config.Config, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) (*App, error) {
func NewApp(conf *config.Config, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) *App {
return &App{
t: t,
conf: conf,
databaseServerRepo: databaseServerRepo,
}, nil
}
}
// pgadminLanguage 面板语言映射为 pgAdmin 语言代码
@@ -86,12 +86,12 @@ func (s *App) Status() string {
}
func (s *App) path() string {
return fmt.Sprintf("%s/server/pgadmin", app.Root)
return app.Root + "/server/pgadmin"
}
// port 从 systemd 环境文件中解析监听端口
func (s *App) port() (uint, error) {
conf, err := io.Read(fmt.Sprintf("%s/pgadmin.conf", s.path()))
conf, err := io.Read(s.path() + "/pgadmin.conf")
if err != nil {
return 0, err
}
@@ -105,7 +105,7 @@ func (s *App) port() (uint, error) {
// credential 读取安装时生成的初始凭据(邮箱与密码)
func (s *App) credential() (string, string) {
raw, err := io.Read(fmt.Sprintf("%s/credential", s.path()))
raw, err := io.Read(s.path() + "/credential")
if err != nil {
return "", ""
}
@@ -139,7 +139,7 @@ func (s *App) UpdatePort(w http.ResponseWriter, r *http.Request) {
return
}
conf := fmt.Sprintf("%s/pgadmin.conf", s.path())
conf := s.path() + "/pgadmin.conf"
content, err := io.Read(conf)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -507,7 +507,7 @@ func (s *App) UpdateUsername(w http.ResponseWriter, r *http.Request) {
}
// 更新凭据文件,下次登录按新账号重新同步
if err = io.Write(fmt.Sprintf("%s/credential", s.path()), req.Username+"\n"+password+"\n", 0600); err != nil {
if err = io.Write(s.path()+"/credential", req.Username+"\n"+password+"\n", 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -539,7 +539,7 @@ func (s *App) ResetPassword(w http.ResponseWriter, r *http.Request) {
return
}
if err = io.Write(fmt.Sprintf("%s/credential", s.path()), email+"\n"+req.Password+"\n", 0600); err != nil {
if err = io.Write(s.path()+"/credential", email+"\n"+req.Password+"\n", 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
+8 -8
View File
@@ -35,12 +35,12 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(conf *config.Config, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) (*App, error) {
func NewApp(conf *config.Config, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) *App {
return &App{
t: t,
conf: conf,
databaseServerRepo: databaseServerRepo,
}, nil
}
}
func (s *App) Route(r chi.Router) {
@@ -59,7 +59,7 @@ func (s *App) Status() string {
// info 获取 phpMyAdmin 的访问目录与端口
func (s *App) info() (string, int, error) {
files, err := os.ReadDir(fmt.Sprintf("%s/server/phpmyadmin", app.Root))
files, err := os.ReadDir(app.Root + "/server/phpmyadmin")
if err != nil {
return "", 0, errors.New(s.t.Get("phpMyAdmin directory not found"))
}
@@ -74,7 +74,7 @@ func (s *App) info() (string, int, error) {
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
}
@@ -248,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
}
@@ -282,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
@@ -298,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
}
+2 -2
View File
@@ -13,8 +13,8 @@ import (
type App struct{}
func NewApp() (*App, error) {
return &App{}, nil
func NewApp() *App {
return &App{}
}
func (s *App) Route(r chi.Router) {
+606 -150
View File
@@ -1,19 +1,25 @@
package postgresql
import (
"context"
"fmt"
"net/http"
"os"
"net/url"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/spf13/cast"
"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"
@@ -21,21 +27,24 @@ import (
"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, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) (*App, error) {
setting := settingRepo
databaseServer := databaseServerRepo
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,
}, nil
conf: conf,
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
taskRepo: taskRepo,
}
}
func (s *App) Route(r chi.Router) {
@@ -49,6 +58,23 @@ func (s *App) Route(r chi.Router) {
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 {
@@ -76,7 +102,7 @@ func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
oldPort := s.getPort()
oldPort := db.PostgresPort(app.Root)
if err = io.Write(s.configPath(), req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -93,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
@@ -110,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
}
@@ -137,19 +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
}
defer func() { _ = os.Unsetenv("PGPASSWORD") }()
port := s.getPort()
start, err := shell.Execf(`psql -h 127.0.0.1 -p %d -U postgres -t -c "select pg_postmaster_start_time();" | head -1 | cut -d'.' -f1`, port)
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 -p %d -U postgres -t -c "select pg_backend_pid();"`, port)
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
@@ -159,12 +180,12 @@ 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 -p %d -U postgres -t -c "SELECT count(*) FROM pg_stat_activity WHERE NOT pid=pg_backend_pid();"`, port)
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 -p %d -U postgres -t -c "select pg_size_pretty(pg_database_size('postgres'));"`, port)
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
@@ -183,7 +204,7 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
// Log 获取应用日志路径列表
func (s *App) Log(w http.ResponseWriter, r *http.Request) {
paths, err := filepath.Glob(fmt.Sprintf("%s/server/postgresql/logs/postgresql-*.log", app.Root))
paths, err := filepath.Glob(app.Root + "/server/postgresql/logs/postgresql-*.log")
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -212,7 +233,7 @@ func (s *App) SetPostgresPassword(w http.ResponseWriter, r *http.Request) {
}
oldPassword, _ := s.settingRepo.Get(biz.SettingKeyPostgresPassword)
port := s.getPort()
port := db.PostgresPort(app.Root)
postgres, err := db.NewPostgres(r.Context(), "postgres", oldPassword, "127.0.0.1", port)
if err != nil {
// 直接修改密码
@@ -248,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)
@@ -288,7 +309,7 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
}
confPath := s.configPath()
oldPort := s.getPort()
oldPort := db.PostgresPort(app.Root)
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -296,32 +317,32 @@ 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)
@@ -336,22 +357,533 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
func (s *App) configPath() string {
return fmt.Sprintf("%s/server/postgresql/data/postgresql.conf", app.Root)
// 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]
}
}
}
service.Success(w, extensions)
}
// getPort 读取 PostgreSQL 端口
func (s *App) getPort() uint {
// 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
}
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 -- '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
}
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 {
return 5432
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
return s.parsePort(config)
// 将 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)),
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.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 !slices.Contains([]string{"vacuum", "analyze", "repack"}, req.Operation) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid operation"))
return
}
var cmd string
switch req.Operation {
case "vacuum":
cmd = fmt.Sprintf(`su - postgres -c 'psql -d "%s" -c "VACUUM \"%s\".\"%s\""'`, req.Database, req.Schema, req.Table)
case "analyze":
cmd = fmt.Sprintf(`su - postgres -c 'psql -d "%s" -c "ANALYZE \"%s\".\"%s\""'`, req.Database, req.Schema, req.Table)
case "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
}
cmd = fmt.Sprintf(`su - postgres -c 'pg_repack -d "%s" -t "%s.%s"'`, req.Database, req.Schema, req.Table)
}
task := new(biz.Task)
task.Key = fmt.Sprintf("postgresql:maintenance:%s:%s.%s", req.Database, req.Schema, req.Table)
task.Name = s.t.Get("Run %s on table %s.%s of database %s", req.Operation, req.Schema, req.Table, 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(s.getPGValue(config, "port"))
port := cast.ToUint(confval.Postgres.Get(config, "port"))
if port == 0 {
return 5432
}
@@ -369,79 +901,3 @@ func (s *App) applyConfig(newConfig string, oldPort uint) error {
}
return s.databaseServerRepo.UpdatePort("local_postgresql", newPort)
}
// getPGValue 从 PostgreSQL 配置内容中获取指定键的值
func (s *App) getPGValue(content string, key string) string {
lines := strings.SplitSeq(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])
}
// 去除引号
v = strings.Trim(v, "'\"")
return v
}
}
return ""
}
// setPGValue 在 PostgreSQL 配置内容中设置指定键的值
func (s *App) setPGValue(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.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 !found && value != "" {
result = append(result, key+" = '"+value+"'")
}
return strings.Join(result, "\n")
}
+119
View File
@@ -8,6 +8,125 @@ type SetPostgresPassword struct {
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"`
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"`
}
// MaintenanceRun 表维护操作请求
type MaintenanceRun struct {
Database string `form:"database" json:"database" validate:"required"`
Schema string `form:"schema" json:"schema" validate:"required"`
Table string `form:"table" json:"table" validate:"required"`
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 {
// 连接设置
+9 -25
View File
@@ -15,6 +15,7 @@ import (
"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"
@@ -29,9 +30,9 @@ type App struct {
taskRepo biz.TaskRepo
}
func NewApp(conf *config.Config, t *gotext.Locale, taskRepo biz.TaskRepo) (*App, error) {
func NewApp(conf *config.Config, t *gotext.Locale, taskRepo biz.TaskRepo) *App {
return &App{t: t, conf: conf, taskRepo: taskRepo}, nil
return &App{t: t, conf: conf, taskRepo: taskRepo}
}
func (s *App) Route(r chi.Router) {
@@ -105,33 +106,16 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
conf, _ := io.Read(fmt.Sprintf("%s/server/prometheus/prometheus.yml", app.Root))
service.Success(w, conf)
common.ServeConfig(w, app.Root+"/server/prometheus/prometheus.yml")
}
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/prometheus/prometheus.yml", app.Root), req.Config, 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)
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(fmt.Sprintf("%s/server/prometheus/prometheus.yml", app.Root))
conf, _ := io.Read(app.Root + "/server/prometheus/prometheus.yml")
var cfg struct {
Global struct {
@@ -159,7 +143,7 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
return
}
confPath := fmt.Sprintf("%s/server/prometheus/prometheus.yml", app.Root)
confPath := app.Root + "/server/prometheus/prometheus.yml"
raw, _ := io.Read(confPath)
var cfg map[string]any
@@ -205,7 +189,7 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
// GetAlertmanagerConfig 获取 Alertmanager 配置
func (s *App) GetAlertmanagerConfig(w http.ResponseWriter, r *http.Request) {
conf, _ := io.Read(fmt.Sprintf("%s/server/prometheus/alertmanager/alertmanager.yml", app.Root))
conf, _ := io.Read(app.Root + "/server/prometheus/alertmanager/alertmanager.yml")
service.Success(w, conf)
}
@@ -217,7 +201,7 @@ func (s *App) UpdateAlertmanagerConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err = io.Write(fmt.Sprintf("%s/server/prometheus/alertmanager/alertmanager.yml", app.Root), req.Config, 0644); err != nil {
if err = io.Write(app.Root+"/server/prometheus/alertmanager/alertmanager.yml", req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
+25 -86
View File
@@ -13,6 +13,7 @@ import (
"github.com/spf13/cast"
"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"
@@ -25,11 +26,11 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}, nil
}
}
func (s *App) Route(r chi.Router) {
@@ -148,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]))
@@ -171,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
@@ -206,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)
@@ -234,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)
@@ -262,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.SplitSeq(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")
}
+298 -107
View File
@@ -1,36 +1,53 @@
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/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
databaseServerRepo biz.DatabaseServerRepo
taskRepo biz.TaskRepo
slug string // 服务名与配置目录名,如 redis、valkey
name string // 展示名,如 Redis、Valkey
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) (*App, error) {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
return New("redis", "Redis", t, databaseServerRepo, taskRepo)
}
databaseServer := databaseServerRepo
func New(slug, name string, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
return &App{
t: t,
databaseServerRepo: databaseServer,
}, nil
databaseServerRepo: databaseServerRepo,
taskRepo: taskRepo,
slug: slug,
name: name,
}
}
func (s *App) Route(r chi.Router) {
@@ -39,17 +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("redis")
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 {
@@ -57,9 +81,9 @@ 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
@@ -70,9 +94,9 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
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
}
@@ -104,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 {
@@ -165,96 +167,285 @@ 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_redis", req.Requirepass)
_ = s.databaseServerRepo.UpdatePassword("local_"+s.slug, req.Requirepass)
service.Success(w, nil)
}
// getRedisValue 从 Redis 配置内容中获取指定键的值
func (s *App) getRedisValue(content string, key string) string {
lines := strings.SplitSeq(content, "\n")
for line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
// 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
}
parts := strings.Fields(trimmed)
if len(parts) >= 2 && parts[0] == key {
return strings.Join(parts[1:], " ")
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)
}
return ""
service.Success(w, entries)
}
// setRedisValue 在 Redis 配置内容中设置指定键的值
func (s *App) setRedisValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
// 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()
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)
if _, err = conn.Exec("SLOWLOG", "RESET"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// 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
}
checkLine := trimmed
if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
}
parts := strings.Fields(checkLine)
if len(parts) >= 1 && parts[0] == key {
if found {
continue
fields := map[string]string{}
for kv := range strings.FieldsSeq(line) {
if key, value, found := strings.Cut(kv, "="); found {
fields[key] = value
}
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)
}
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"],
})
}
if !found && value != "" {
result = append(result, key+" "+value)
}
return strings.Join(result, "\n")
service.Success(w, clients)
}
// 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
}
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 = ""
}
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
}
// 值可能为整数或字符串,嵌套数组(如 db.0)跳过
if v, vErr := redigo.Int64(values[i+1], nil); vErr == nil {
stats[key] = cast.ToString(v)
continue
}
if v, vErr := redigo.String(values[i+1], nil); vErr == nil {
stats[key] = v
}
}
// 各版本键名存在差异,仅展示存在的指标
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},
}
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")
}
+33 -4
View File
@@ -1,10 +1,8 @@
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"`
@@ -20,3 +18,34 @@ type ConfigTune struct {
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"`
Items []types.NV `json:"items"`
}
+26 -84
View File
@@ -4,12 +4,12 @@ import (
"fmt"
"net/http"
"regexp"
"strings"
"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"
@@ -20,9 +20,9 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
func NewApp(t *gotext.Locale) *App {
return &App{t: t}, nil
return &App{t: t}
}
func (s *App) Route(r chi.Router) {
@@ -62,11 +62,11 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
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: s.getPropertiesValue(config, "brokerName")},
{Name: s.t.Get("Listen Port"), Value: s.getPropertiesValue(config, "listenPort")},
{Name: s.t.Get("NameServer Address"), Value: s.getPropertiesValue(config, "namesrvAddr")},
{Name: s.t.Get("Broker Role"), Value: s.getPropertiesValue(config, "brokerRole")},
{Name: s.t.Get("Flush Disk Type"), Value: s.getPropertiesValue(config, "flushDiskType")},
{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)
@@ -106,14 +106,14 @@ func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
brokerInit, brokerMax := s.parseHeapLine(heapRaw, "ROCKETMQ_BROKER_HEAP")
tune := ConfigTune{
BrokerName: s.getPropertiesValue(config, "brokerName"),
ListenPort: s.getPropertiesValue(config, "listenPort"),
NamesrvAddr: s.getPropertiesValue(config, "namesrvAddr"),
BrokerRole: s.getPropertiesValue(config, "brokerRole"),
FlushDiskType: s.getPropertiesValue(config, "flushDiskType"),
StorePathRootDir: s.getPropertiesValue(config, "storePathRootDir"),
StorePathCommitLog: s.getPropertiesValue(config, "storePathCommitLog"),
MaxMessageSize: s.getPropertiesValue(config, "maxMessageSize"),
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,
@@ -133,14 +133,14 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
config, _ := io.Read(s.configPath())
config = s.setPropertiesValue(config, "brokerName", req.BrokerName)
config = s.setPropertiesValue(config, "listenPort", req.ListenPort)
config = s.setPropertiesValue(config, "namesrvAddr", req.NamesrvAddr)
config = s.setPropertiesValue(config, "brokerRole", req.BrokerRole)
config = s.setPropertiesValue(config, "flushDiskType", req.FlushDiskType)
config = s.setPropertiesValue(config, "storePathRootDir", req.StorePathRootDir)
config = s.setPropertiesValue(config, "storePathCommitLog", req.StorePathCommitLog)
config = s.setPropertiesValue(config, "maxMessageSize", req.MaxMessageSize)
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)
@@ -175,70 +175,12 @@ func (s *App) restartServices() error {
// configPath 返回 broker 配置文件路径
func (s *App) configPath() string {
return fmt.Sprintf("%s/server/rocketmq/conf/broker.conf", app.Root)
return app.Root + "/server/rocketmq/conf/broker.conf"
}
// heapEnvPath 返回 JVM 堆内存配置文件路径
func (s *App) heapEnvPath() string {
return fmt.Sprintf("%s/server/rocketmq/conf/heap.env", app.Root)
}
// getPropertiesValue 从 properties 内容中获取指定键的值
func (s *App) getPropertiesValue(content string, key string) string {
for line := range strings.SplitSeq(content, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
k, v, ok := strings.Cut(trimmed, "=")
if ok && strings.TrimSpace(k) == key {
return strings.TrimSpace(v)
}
}
return ""
}
// setPropertiesValue 在 properties 内容中设置指定键的值
func (s *App) setPropertiesValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
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)
continue
}
checkLine := trimmed
if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
}
k, _, ok := strings.Cut(checkLine, "=")
if ok && strings.TrimSpace(k) == 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 !found && value != "" {
result = append(result, key+"="+value)
}
return strings.Join(result, "\n")
return app.Root + "/server/rocketmq/conf/heap.env"
}
// parseHeapLine 从 heap.env 中提取指定环境变量的堆内存配置
+24 -175
View File
@@ -1,19 +1,15 @@
package rsync
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix/v2"
"github.com/libtnb/utils/str"
"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/shell"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -22,11 +18,11 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}, nil
}
}
func (s *App) Route(r chi.Router) {
@@ -44,62 +40,12 @@ func (s *App) Status() string {
}
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{
@@ -109,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
}
@@ -252,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"`
}
+2 -2
View File
@@ -23,11 +23,11 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) (*App, error) {
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}, nil
}
}
func (s *App) Route(r chi.Router) {
+92 -114
View File
@@ -7,8 +7,11 @@ import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix/v2"
"github.com/samber/lo"
"github.com/spf13/cast"
"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"
@@ -22,19 +25,11 @@ type App struct {
name string
}
func NewApp(t *gotext.Locale) (*App, error) {
var name string
if os.IsRHEL() {
name = "supervisord"
} else {
name = "supervisor"
}
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
name: name,
}, nil
name: lo.Ternary(os.IsRHEL(), "supervisord", "supervisor"),
}
}
func (s *App) Route(r chi.Router) {
@@ -48,8 +43,10 @@ func (s *App) Route(r chi.Router) {
r.Get("/processes/{process}/log", s.ProcessLog)
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 获取服务名称
@@ -64,47 +61,12 @@ func (s *App) Status() string {
// 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 进程列表
@@ -206,14 +168,7 @@ func (s *App) ProcessLog(w http.ResponseWriter, r *http.Request) {
return
}
name := programName(req.Process)
var logPath string
if os.IsRHEL() {
logPath, err = shell.Execf(`cat '/etc/supervisord.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, name)
} else {
logPath, err = shell.Execf(`cat '/etc/supervisor/conf.d/%s.conf' | grep stdout_logfile= | awk -F "=" '{print $2}'`, name)
}
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,7 +177,7 @@ func (s *App) ProcessLog(w http.ResponseWriter, r *http.Request) {
service.Success(w, logPath)
}
// ProcessConfig 获取进程配置
// ProcessConfig 获取进程配置原文
func (s *App) ProcessConfig(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ProcessName](r)
if err != nil {
@@ -230,14 +185,7 @@ func (s *App) ProcessConfig(w http.ResponseWriter, r *http.Request) {
return
}
name := programName(req.Process)
var config string
if os.IsRHEL() {
config, err = io.Read(`/etc/supervisord.d/` + name + `.conf`)
} else {
config, err = io.Read(`/etc/supervisor/conf.d/` + name + `.conf`)
}
config, err := io.Read(confPath(programName(req.Process)))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -246,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 {
@@ -255,22 +203,56 @@ func (s *App) UpdateProcessConfig(w http.ResponseWriter, r *http.Request) {
}
name := programName(req.Process)
if os.IsRHEL() {
err = io.Write(`/etc/supervisord.d/`+name+`.conf`, req.Config, 0644)
} else {
err = io.Write(`/etc/supervisor/conf.d/`+name+`.conf`, req.Config, 0644)
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:'`, name)
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 添加进程
@@ -281,37 +263,27 @@ func (s *App) CreateProcess(w http.ResponseWriter, r *http.Request) {
return
}
processName := `%(program_name)s`
if req.Num > 1 {
processName = `%(program_name)s_%(process_num)02d`
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=` + processName + `
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
`
var confPath string
if os.IsRHEL() {
confPath = `/etc/supervisord.d/` + req.Name + `.conf`
} else {
confPath = `/etc/supervisor/conf.d/` + req.Name + `.conf`
}
if io.Exists(confPath) {
service.Error(w, http.StatusConflict, s.t.Get("process %s already exists", req.Name))
return
}
if err = io.Write(confPath, config, 0644); err != nil {
if err = io.Write(path, config, 0644); err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
@@ -337,39 +309,45 @@ func (s *App) DeleteProcess(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}'`, name)
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/` + name + `.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}'`, name)
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/` + name + `.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`)
}
+5 -4
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,6 +9,11 @@ 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"`
+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"`
}
+7 -239
View File
@@ -1,259 +1,27 @@
package valkey
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/redis"
"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
databaseServerRepo biz.DatabaseServerRepo
redis *redis.App
}
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) (*App, error) {
databaseServer := databaseServerRepo
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, taskRepo biz.TaskRepo) *App {
return &App{
t: t,
databaseServerRepo: databaseServer,
}, nil
redis: redis.New("valkey", "Valkey", t, databaseServerRepo, 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)
s.redis.Route(r)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("valkey")
return types.AggregateAppStatus(ok)
}
func (s *App) Load(w http.ResponseWriter, r *http.Request) {
status, err := systemctl.Status("valkey")
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get valkey status: %v", err))
return
}
if !status {
service.Success(w, []types.NV{})
return
}
// 检查 Valkey 密码
withPassword := ""
config, err := io.Read(fmt.Sprintf("%s/server/valkey/valkey.conf", app.Root))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
re := regexp.MustCompile(`(?m)^requirepass\s+(.+)`)
matches := re.FindStringSubmatch(config)
if len(matches) == 2 {
withPassword = " -a " + matches[1]
}
raw, err := shell.Execf("valkey-cli%s info", withPassword)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get valkey info: %v", err))
return
}
dataRaw := lo.SliceToMap(strings.Split(raw, "\n"), func(item string) (string, string) {
parts := strings.Split(item, ":")
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"]},
{Name: s.t.Get("Uptime in Days"), Value: dataRaw["uptime_in_days"]},
{Name: s.t.Get("Connected Clients"), Value: dataRaw["connected_clients"]},
{Name: s.t.Get("Total Allocated Memory"), Value: dataRaw["used_memory_human"]},
{Name: s.t.Get("Total Memory Usage"), Value: dataRaw["used_memory_rss_human"]},
{Name: s.t.Get("Peak Memory Usage"), Value: dataRaw["used_memory_peak_human"]},
{Name: s.t.Get("Memory Fragmentation Ratio"), Value: dataRaw["mem_fragmentation_ratio"]},
{Name: s.t.Get("Total Connections Received"), Value: dataRaw["total_connections_received"]},
{Name: s.t.Get("Total Commands Processed"), Value: dataRaw["total_commands_processed"]},
{Name: s.t.Get("Commands Per Second"), Value: dataRaw["instantaneous_ops_per_sec"]},
{Name: s.t.Get("Keyspace Hits"), Value: dataRaw["keyspace_hits"]},
{Name: s.t.Get("Keyspace Misses"), Value: dataRaw["keyspace_misses"]},
{Name: s.t.Get("Latest Fork Time (ms)"), Value: dataRaw["latest_fork_usec"]},
}
service.Success(w, data)
}
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/valkey/valkey.conf", app.Root))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, config)
}
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/valkey/valkey.conf", app.Root), req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("valkey"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// GetConfigTune 获取 Valkey 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/valkey/valkey.conf", app.Root))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
tune := ConfigTune{
Bind: s.getValkeyValue(config, "bind"),
Port: s.getValkeyValue(config, "port"),
Databases: s.getValkeyValue(config, "databases"),
Requirepass: s.getValkeyValue(config, "requirepass"),
Timeout: s.getValkeyValue(config, "timeout"),
TCPKeepalive: s.getValkeyValue(config, "tcp-keepalive"),
Maxmemory: s.getValkeyValue(config, "maxmemory"),
MaxmemoryPolicy: s.getValkeyValue(config, "maxmemory-policy"),
Appendonly: s.getValkeyValue(config, "appendonly"),
Appendfsync: s.getValkeyValue(config, "appendfsync"),
}
service.Success(w, tune)
}
// UpdateConfigTune 更新 Valkey 配置调整参数
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 := fmt.Sprintf("%s/server/valkey/valkey.conf", app.Root)
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
config = s.setValkeyValue(config, "bind", req.Bind)
config = s.setValkeyValue(config, "port", req.Port)
config = s.setValkeyValue(config, "databases", req.Databases)
config = s.setValkeyValue(config, "requirepass", req.Requirepass)
config = s.setValkeyValue(config, "timeout", req.Timeout)
config = s.setValkeyValue(config, "tcp-keepalive", req.TCPKeepalive)
config = s.setValkeyValue(config, "maxmemory", req.Maxmemory)
config = s.setValkeyValue(config, "maxmemory-policy", req.MaxmemoryPolicy)
config = s.setValkeyValue(config, "appendonly", req.Appendonly)
config = s.setValkeyValue(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("valkey"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 同步密码到数据库服务器记录
_ = s.databaseServerRepo.UpdatePassword("local_valkey", req.Requirepass)
service.Success(w, nil)
}
// getValkeyValue 从 Valkey 配置内容中获取指定键的值
func (s *App) getValkeyValue(content string, key string) string {
lines := strings.SplitSeq(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 ""
}
// setValkeyValue 在 Valkey 配置内容中设置指定键的值
func (s *App) setValkeyValue(content string, key string, value string) string {
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
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)
continue
}
checkLine := trimmed
if strings.HasPrefix(checkLine, "#") {
checkLine = strings.TrimSpace(checkLine[1:])
}
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 !found && value != "" {
result = append(result, key+" "+value)
}
return strings.Join(result, "\n")
return s.redis.Status()
}
-22
View File
@@ -1,22 +0,0 @@
package valkey
type UpdateConfig struct {
Config string `form:"config" json:"config" validate:"required"`
}
// ConfigTune Valkey 配置调整
type ConfigTune struct {
// 常规设置
Bind string `form:"bind" json:"bind"`
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" 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" validate:"in:noeviction,allkeys-lru,allkeys-lfu,volatile-lru,volatile-lfu,allkeys-random,volatile-random,volatile-ttl"`
// 持久化
Appendonly string `form:"appendonly" json:"appendonly" validate:"in:yes,no"`
Appendfsync string `form:"appendfsync" json:"appendfsync" validate:"in:always,everysec,no"`
}
+259 -227
View File
@@ -3,9 +3,12 @@ package biz
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"os"
"slices"
"strings"
"sync"
@@ -51,14 +54,6 @@ const (
AlertTypeWebsiteExpire = "website_expire" // 网站剩余天数,目标为网站名
)
// statusAlertTypes 状态类指标,语义固定为「不在运行」,不需要运算符与阈值
var statusAlertTypes = []string{AlertTypeService, AlertTypeProject, AlertTypeContainer, AlertTypeApp, AlertTypeDatabase}
// IsStatusAlert 是否为状态类指标
func IsStatusAlert(typ string) bool {
return slices.Contains(statusAlertTypes, typ)
}
const (
// alertRetryDelay 通知发送失败后的重试间隔
alertRetryDelay = 5 * time.Minute
@@ -66,6 +61,14 @@ const (
sshFailThreshold uint = 5
// sshFailSilence 同一来源两次爆破告警的最小间隔
sshFailSilence = 30 * time.Minute
// sshScanTimeout 单轮 sshd 日志扫描超时,避免 journalctl 卡死拖垮整个告警评估
sshScanTimeout = 30 * time.Second
// sshScanLimit 单轮最多读取的日志条数,防止爆破期间读入过多日志
sshScanLimit = 5000
// sshCursorPrefix journalctl --show-cursor 在输出末尾打印的游标行前缀
sshCursorPrefix = "-- cursor: "
// sshScanBytes 单轮最多读取的文本日志字节数,防止停机过久后一次读入过多
sshScanBytes int64 = 8 << 20
)
// 告警比较运算符
@@ -159,17 +162,19 @@ type AlertUsecase struct {
mu sync.Mutex
hits map[string]uint // 连续命中次数
fired map[string]time.Time // 上次通知时间
silenced map[string]time.Time // 静默截止时间,此前不重复通知
netSnaps map[string]ioSnapshot // 网卡累计流量快照
diskSnaps map[string]ioSnapshot // 磁盘累计 IO 快照
healthKeys map[string]struct{} // 已通知的健康问题
sshFired map[string]time.Time // SSH 爆破上次通知时间
sshAt time.Time // SSH 日志上次检查时间
dbProbe map[string]bool // 本轮数据库连通性探测结果
cleanedAt time.Time
sshAt time.Time // SSH 日志上次检查时间journald 回退路径)
sshCursor string // SSH 日志上次读取到的 journal 游标
sshLog string // 正在跟踪的 sshd 文本日志路径
sshOffset int64 // 文本日志上次读到的字节偏移
cleanedAt time.Time // 上次清理历史告警的时间
}
func NewAlertUsecase(notifyUsecase *NotifyUsecase, loader *apploader.Loader, t *gotext.Locale, log *slog.Logger, alertRepo AlertRepo, appRepo AppRepo, containerRepo ContainerRepo, databaseServerRepo DatabaseServerRepo, settingRepo SettingRepo) (*AlertUsecase, error) {
func NewAlertUsecase(notifyUsecase *NotifyUsecase, loader *apploader.Loader, t *gotext.Locale, log *slog.Logger, alertRepo AlertRepo, appRepo AppRepo, containerRepo ContainerRepo, databaseServerRepo DatabaseServerRepo, settingRepo SettingRepo) *AlertUsecase {
return &AlertUsecase{
repo: alertRepo,
notify: notifyUsecase,
@@ -181,12 +186,12 @@ func NewAlertUsecase(notifyUsecase *NotifyUsecase, loader *apploader.Loader, t *
log: log,
t: t,
hits: make(map[string]uint),
fired: make(map[string]time.Time),
silenced: make(map[string]time.Time),
netSnaps: make(map[string]ioSnapshot),
diskSnaps: make(map[string]ioSnapshot),
healthKeys: make(map[string]struct{}),
sshFired: make(map[string]time.Time),
}, nil
}
}
func (uc *AlertUsecase) ListRules(page, limit uint) ([]*AlertRule, int64, error) {
@@ -209,7 +214,7 @@ func (uc *AlertUsecase) CreateRule(ctx context.Context, req *request.AlertRuleCr
Channels: req.Channels,
Enabled: req.Enabled,
}
normalizeRule(rule)
uc.normalizeRule(rule)
if err := uc.repo.CreateRule(rule); err != nil {
return nil, err
@@ -235,7 +240,7 @@ func (uc *AlertUsecase) UpdateRule(ctx context.Context, req *request.AlertRuleUp
rule.Silence = req.Silence
rule.Channels = req.Channels
rule.Enabled = req.Enabled
normalizeRule(rule)
uc.normalizeRule(rule)
if err = uc.repo.UpdateRule(rule); err != nil {
return err
@@ -284,55 +289,61 @@ func (uc *AlertUsecase) Evaluate(ctx context.Context) error {
return err
}
// 探测结果按轮缓存,进入新一轮先失效
uc.mu.Lock()
uc.dbProbe = nil
uc.mu.Unlock()
uc.cleanup()
uc.checkHealth(ctx)
uc.checkSSH(ctx)
var enabled []*AlertRule
for _, rule := range rules {
if rule.Enabled {
enabled = append(enabled, rule)
}
}
enabled := lo.Filter(rules, func(rule *AlertRule, _ int) bool { return rule.Enabled })
if len(enabled) == 0 {
uc.mu.Lock()
clear(uc.hits)
clear(uc.fired)
clear(uc.silenced)
uc.mu.Unlock()
return nil
}
info := tools.CurrentInfo(nil, nil)
now := time.Now()
uc.updateSnapshots(info, now)
var info types.CurrentInfo
if lo.SomeBy(enabled, func(rule *AlertRule) bool { return uc.needsSystemInfo(rule.Type) }) {
info = tools.CurrentInfo(nil, nil)
uc.updateSnapshots(info, now)
}
// 同类型规则共用一次采集结果
collected := make(map[string][]*AlertMetric)
alive := make(map[string]struct{})
for _, rule := range enabled {
metrics, err := uc.collect(ctx, rule, info)
if err != nil {
uc.log.Warn("failed to collect alert metric", slog.String("rule", rule.Name), slog.Any("err", err))
continue
cacheKey := rule.Type
if rule.Type == AlertTypeService {
cacheKey += ":" + rule.Target
}
metrics, ok := collected[cacheKey]
if !ok {
var err error
if metrics, err = uc.collect(ctx, rule, info); err != nil {
uc.log.Warn("failed to collect alert metric", slog.String("rule", rule.Name), slog.Any("err", err))
continue
}
collected[cacheKey] = metrics
}
for _, metric := range metrics {
key := stateKey(rule.ID, metric.Target)
for _, metric := range uc.filterMetrics(rule, metrics) {
key := uc.stateKey(rule.ID, metric.Target)
alive[key] = struct{}{}
uc.evaluateMetric(ctx, rule, metric, key, now)
}
}
// 清理已消失的目标状态
// 清理已消失的目标状态;静默记录额外要求已过期,避免某轮采集失败误清后重复告警
uc.mu.Lock()
for key := range uc.hits {
if _, ok := alive[key]; !ok {
delete(uc.hits, key)
}
}
maps.DeleteFunc(uc.hits, func(key string, _ uint) bool {
_, ok := alive[key]
return !ok
})
maps.DeleteFunc(uc.silenced, func(key string, until time.Time) bool {
_, ok := alive[key]
return !ok && now.After(until)
})
uc.mu.Unlock()
return nil
@@ -340,26 +351,25 @@ func (uc *AlertUsecase) Evaluate(ctx context.Context) error {
func (uc *AlertUsecase) evaluateMetric(ctx context.Context, rule *AlertRule, metric *AlertMetric, key string, now time.Time) {
uc.mu.Lock()
if !matchThreshold(rule, metric.Value) {
if !uc.matchThreshold(rule, metric.Value) {
delete(uc.hits, key)
uc.mu.Unlock()
return
}
uc.hits[key]++
hits := uc.hits[key]
duration := max(rule.Duration, 1)
if hits < duration {
if uc.hits[key] < max(rule.Duration, 1) {
uc.mu.Unlock()
return
}
// 静默期内不重复告警
if last, ok := uc.fired[key]; ok && now.Sub(last) < time.Duration(rule.Silence)*time.Minute {
if now.Before(uc.silenced[key]) {
uc.mu.Unlock()
return
}
uc.fired[key] = now
silence := time.Duration(rule.Silence) * time.Minute
uc.silenced[key] = now.Add(silence)
uc.mu.Unlock()
alert := &Alert{
@@ -386,16 +396,16 @@ func (uc *AlertUsecase) evaluateMetric(ctx context.Context, rule *AlertRule, met
// 一条都没送达时缩短静默期,让下一轮重试,避免临时故障吞掉整个静默窗口
if len(rule.Channels) > 0 && sent == 0 {
uc.mu.Lock()
uc.fired[key] = now.Add(alertRetryDelay - time.Duration(rule.Silence)*time.Minute)
uc.silenced[key] = now.Add(min(alertRetryDelay, silence))
uc.mu.Unlock()
}
if err := uc.repo.AddAlert(alert); err != nil {
if err = uc.repo.AddAlert(alert); err != nil {
uc.log.Warn("failed to save alert record", slog.String("rule", rule.Name), slog.Any("err", err))
}
}
// collect 采集规则对应的目标取值
// collect 采集规则类型对应的全部目标取值,不做目标过滤
func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types.CurrentInfo) ([]*AlertMetric, error) {
switch rule.Type {
case AlertTypeCPU:
@@ -417,21 +427,18 @@ func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types
if info.Load == nil {
return nil, nil
}
value := info.Load.Load1
switch rule.Type {
case AlertTypeLoad1:
return []*AlertMetric{{Value: info.Load.Load1}}, nil
case AlertTypeLoad5:
return []*AlertMetric{{Value: info.Load.Load5}}, nil
default:
return []*AlertMetric{{Value: info.Load.Load15}}, nil
value = info.Load.Load5
case AlertTypeLoad15:
value = info.Load.Load15
}
return []*AlertMetric{{Value: value}}, nil
case AlertTypeDisk, AlertTypeDiskInode:
metrics := make([]*AlertMetric, 0)
metrics := make([]*AlertMetric, 0, len(info.DiskUsage))
for _, usage := range info.DiskUsage {
if rule.Target != "" && rule.Target != usage.Path {
continue
}
value := usage.UsedPercent
if rule.Type == AlertTypeDiskInode {
value = usage.InodesUsedPercent
@@ -441,7 +448,7 @@ func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types
return metrics, nil
case AlertTypeNetIn, AlertTypeNetOut, AlertTypeDiskRead, AlertTypeDiskWrite:
return uc.rateMetrics(rule), nil
return uc.rateMetrics(rule.Type), nil
case AlertTypeWebsite5xx, AlertTypeWebsiteError:
stats, err := uc.repo.WebsiteHourStats()
@@ -450,9 +457,6 @@ func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types
}
metrics := make([]*AlertMetric, 0, len(stats))
for _, item := range stats {
if rule.Target != "" && rule.Target != item.Site {
continue
}
if rule.Type == AlertTypeWebsite5xx {
metrics = append(metrics, &AlertMetric{Target: item.Site, Value: float64(item.Status5xx)})
continue
@@ -469,16 +473,18 @@ func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types
if rule.Target == "" {
return nil, nil
}
return []*AlertMetric{{Target: rule.Target, Value: notRunning(systemctl.Status(rule.Target))}}, nil
running, _ := systemctl.Status(rule.Target)
return []*AlertMetric{{Target: rule.Target, Value: uc.statusValue(running)}}, nil
case AlertTypeProject:
names, err := uc.repo.ProjectNames()
if err != nil {
return nil, err
}
// 项目即 systemd 单元,单元名与项目名一致
return lop.Map(filterNames(rule.Target, names), func(name string, _ int) *AlertMetric {
return &AlertMetric{Target: name, Value: notRunning(systemctl.Status(name))}
// 项目即 systemd 单元,单元名与项目名一致,状态并发查询
return lop.Map(names, func(name string, _ int) *AlertMetric {
running, _ := systemctl.Status(name)
return &AlertMetric{Target: name, Value: uc.statusValue(running)}
}), nil
case AlertTypeContainer:
@@ -488,14 +494,7 @@ func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types
}
metrics := make([]*AlertMetric, 0, len(containers))
for _, item := range containers {
if rule.Target != "" && rule.Target != item.Name {
continue
}
value := float64(0)
if item.State != "running" {
value = 1
}
metrics = append(metrics, &AlertMetric{Target: item.Name, Value: value})
metrics = append(metrics, &AlertMetric{Target: item.Name, Value: uc.statusValue(item.State == "running")})
}
return metrics, nil
@@ -505,9 +504,6 @@ func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types
return nil, err
}
targets := lo.Filter(installed, func(item *App, _ int) bool {
if rule.Target != "" && rule.Target != item.Slug {
return false
}
_, ok := uc.loader.Get(item.Slug)
return ok
})
@@ -519,11 +515,7 @@ func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types
if status == types.AppStatusNA {
return nil
}
value := float64(0)
if status != types.AppStatusRunning {
value = 1
}
return &AlertMetric{Target: item.Slug, Value: value}
return &AlertMetric{Target: item.Slug, Value: uc.statusValue(status == types.AppStatusRunning)}
})), nil
case AlertTypeDatabase:
@@ -531,115 +523,50 @@ func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types
if err != nil {
return nil, err
}
reachable := uc.probeDatabases(ctx, servers)
metrics := make([]*AlertMetric, 0, len(servers))
for _, item := range servers {
if rule.Target != "" && rule.Target != item.Name {
continue
}
value := float64(0)
if !reachable[item.Name] {
value = 1
}
metrics = append(metrics, &AlertMetric{Target: item.Name, Value: value})
}
return metrics, nil
// 并发探测连通性,单台耗时上限由 pkg/db 各驱动的连接超时保证(5~10 秒)
return lop.Map(servers, func(item *DatabaseServer, _ int) *AlertMetric {
return &AlertMetric{Target: item.Name, Value: uc.statusValue(uc.database.CheckServer(ctx, item))}
}), nil
case AlertTypeCertExpire:
metrics, err := uc.repo.CertExpiry()
if err != nil {
return nil, err
}
return filterTarget(rule.Target, metrics), nil
return uc.repo.CertExpiry()
case AlertTypeWebsiteExpire:
metrics, err := uc.repo.WebsiteExpiry()
if err != nil {
return nil, err
}
return filterTarget(rule.Target, metrics), nil
return uc.repo.WebsiteExpiry()
}
return nil, fmt.Errorf("unsupported alert type: %s", rule.Type)
}
// notRunning 将运行状态转为告警取值,未运行记为 1,配合 normalizeRule 的 >=1 判定
func notRunning(running bool, _ error) float64 {
if running {
return 0
}
return 1
}
// filterNames 按目标名筛选,目标为空表示全部
func filterNames(target string, names []string) []string {
if target == "" {
return names
}
if slices.Contains(names, target) {
return []string{target}
}
return nil
}
// filterTarget 按目标名筛选,目标为空表示全部
// 证书目标是逗号分隔的多域名,允许用其中任一域名命中
func filterTarget(target string, metrics []*AlertMetric) []*AlertMetric {
if target == "" {
// filterMetrics 按规则目标筛选采集结果,目标为空表示全部
func (uc *AlertUsecase) filterMetrics(rule *AlertRule, metrics []*AlertMetric) []*AlertMetric {
if rule.Target == "" {
return metrics
}
filtered := make([]*AlertMetric, 0)
for _, metric := range metrics {
if metric.Target == target || slices.Contains(strings.Split(metric.Target, ","), target) {
filtered = append(filtered, metric)
return lo.Filter(metrics, func(metric *AlertMetric, _ int) bool {
if metric.Target == rule.Target {
return true
}
}
return filtered
}
// probeDatabases 并发探测数据库连通性,结果在本轮评估内复用,避免多条规则重复整批探测
// 单台耗时上限由 pkg/db 各驱动的连接超时保证(5~10 秒),并发后整批不会拖长分钟级评估
func (uc *AlertUsecase) probeDatabases(ctx context.Context, servers []*DatabaseServer) map[string]bool {
uc.mu.Lock()
cached := uc.dbProbe
uc.mu.Unlock()
if cached != nil {
return cached
}
probe := lo.FromEntries(lop.Map(servers, func(item *DatabaseServer, _ int) lo.Entry[string, bool] {
return lo.Entry[string, bool]{Key: item.Name, Value: uc.database.CheckServer(ctx, item)}
}))
uc.mu.Lock()
uc.dbProbe = probe
uc.mu.Unlock()
return probe
// 证书目标是逗号分隔的多域名,允许用其中任一域名命中
return rule.Type == AlertTypeCertExpire && slices.Contains(strings.Split(metric.Target, ","), rule.Target)
})
}
// rateMetrics 从快照读取速率指标(MB/s)
func (uc *AlertUsecase) rateMetrics(rule *AlertRule) []*AlertMetric {
func (uc *AlertUsecase) rateMetrics(typ string) []*AlertMetric {
uc.mu.Lock()
defer uc.mu.Unlock()
snaps := uc.netSnaps
if rule.Type == AlertTypeDiskRead || rule.Type == AlertTypeDiskWrite {
if typ == AlertTypeDiskRead || typ == AlertTypeDiskWrite {
snaps = uc.diskSnaps
}
metrics := make([]*AlertMetric, 0, len(snaps))
for name, snap := range snaps {
if rule.Target != "" && rule.Target != name {
continue
}
value := snap.inRate
if rule.Type == AlertTypeNetOut || rule.Type == AlertTypeDiskWrite {
if typ == AlertTypeNetOut || typ == AlertTypeDiskWrite {
value = snap.outRate
}
metrics = append(metrics, &AlertMetric{Target: name, Value: float64(value) / 1024 / 1024})
@@ -658,20 +585,45 @@ func (uc *AlertUsecase) updateSnapshots(info types.CurrentInfo, now time.Time) {
if item.Name == "lo" {
continue
}
nets[item.Name] = rateOf(uc.netSnaps[item.Name], item.BytesRecv, item.BytesSent, now)
nets[item.Name] = uc.rateOf(uc.netSnaps[item.Name], item.BytesRecv, item.BytesSent, now)
}
uc.netSnaps = nets
disks := make(map[string]ioSnapshot, len(info.DiskIO))
for _, item := range info.DiskIO {
disks[item.Name] = rateOf(uc.diskSnaps[item.Name], item.ReadBytes, item.WriteBytes, now)
disks[item.Name] = uc.rateOf(uc.diskSnaps[item.Name], item.ReadBytes, item.WriteBytes, now)
}
uc.diskSnaps = disks
}
// rateOf 依据上次累计值计算每秒增量,首次采集速率为 0
func (uc *AlertUsecase) rateOf(prev ioSnapshot, in, out uint64, now time.Time) ioSnapshot {
snap := ioSnapshot{in: in, out: out, at: now}
if prev.at.IsZero() {
return snap
}
elapsed := now.Sub(prev.at).Seconds()
if elapsed < 1 {
elapsed = 1
}
if in >= prev.in {
snap.inRate = uint64(float64(in-prev.in) / elapsed)
}
if out >= prev.out {
snap.outRate = uint64(float64(out-prev.out) / elapsed)
}
return snap
}
// checkHealth 上报新出现的面板健康问题,问题恢复后重新出现会再次通知
// 同步发送并只在送达后记入去重集合,否则一次发送失败就会让持续存在的问题再也不告警
func (uc *AlertUsecase) checkHealth(ctx context.Context) {
if !uc.notify.EventEnabled(NotifyEventHealth) {
return
}
issues := app.Health.Snapshot()
uc.mu.Lock()
@@ -707,27 +659,14 @@ func (uc *AlertUsecase) checkHealth(ctx context.Context) {
// checkSSH 增量检查 sshd 日志,上报登录成功与爆破尝试
func (uc *AlertUsecase) checkSSH(ctx context.Context) {
// 没人接收就不必读
if !uc.notify.EventEnabled(NotifyEventSSHLogin, NotifyEventSSHBruteforce) {
return
}
now := time.Now()
uc.mu.Lock()
since := uc.sshAt
uc.sshAt = now
uc.mu.Unlock()
// 首次仅记录时间,避免面板启动时把历史日志全推一遍
if since.IsZero() {
return
}
raw, err := shell.ExecfWithContext(ctx, `journalctl -u sshd -u ssh --no-pager -o json --since "@%d" 2>/dev/null`, since.Unix())
if err != nil {
// 读取失败则回退检查点,下一轮重扫该窗口,避免丢掉这段时间的登录记录
uc.mu.Lock()
uc.sshAt = since
uc.mu.Unlock()
return
}
if raw == "" {
raw, err := uc.readSSHLog(ctx)
if err != nil || raw == "" {
return
}
@@ -735,14 +674,17 @@ func (uc *AlertUsecase) checkSSH(ctx context.Context) {
scanner := bufio.NewScanner(strings.NewReader(raw))
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
var entry struct {
Message string `json:"MESSAGE"`
}
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
line := scanner.Text()
// 游标行由 journalctl 的 --show-cursor 在末尾单独打印
if next, ok := strings.CutPrefix(line, sshCursorPrefix); ok {
uc.mu.Lock()
uc.sshCursor = next
uc.mu.Unlock()
continue
}
record := sshlog.ParseMessage(entry.Message)
record := sshlog.ParseMessage(line)
if record == nil {
continue
}
@@ -772,6 +714,87 @@ func (uc *AlertUsecase) checkSSH(ctx context.Context) {
}
}
// readSSHLog 增量读取 sshd 日志
// 优先读文本日志:journald 按单位过滤要沿匹配链遍历,日志量大时取最后一条都要几十秒
func (uc *AlertUsecase) readSSHLog(ctx context.Context) (string, error) {
for _, path := range []string{"/var/log/auth.log", "/var/log/secure"} {
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return uc.readSSHFile(path, info.Size())
}
}
return uc.readSSHJournal(ctx)
}
// readSSHFile 按字节偏移读取文本日志新增的部分
func (uc *AlertUsecase) readSSHFile(path string, size int64) (string, error) {
uc.mu.Lock()
offset, known := uc.sshOffset, uc.sshLog == path
uc.sshLog, uc.sshOffset = path, size
uc.mu.Unlock()
// 首轮只记录位置,避免面板启动时把历史日志全推一遍
if !known {
return "", nil
}
switch {
case offset > size:
offset = 0 // 日志已轮转,从新文件开头读
case size-offset > sshScanBytes:
offset = size - sshScanBytes // 停机过久时只补最近一段,首行可能被截断解析不出
case offset == size:
return "", nil
}
file, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
buffer := make([]byte, size-offset)
n, err := file.ReadAt(buffer, offset)
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
return string(buffer[:n]), nil
}
// readSSHJournal 无文本日志时回退到 journald,按游标增量读取
func (uc *AlertUsecase) readSSHJournal(ctx context.Context) (string, error) {
now := time.Now()
uc.mu.Lock()
since, cursor := uc.sshAt, uc.sshCursor
uc.sshAt = now
uc.mu.Unlock()
limit, position := sshScanLimit, fmt.Sprintf("--after-cursor %q", cursor)
switch {
case since.IsZero():
limit, position = 0, ""
case cursor == "":
position = fmt.Sprintf(`--since "@%d"`, since.Unix())
}
// 只用到 MESSAGE-o cat 免去 journald 侧序列化几十个无关字段和这边的逐行反序列化
scanCtx, cancel := context.WithTimeout(ctx, sshScanTimeout)
defer cancel()
raw, err := shell.ExecfWithContext(scanCtx, `journalctl -u sshd -u ssh --no-pager -q -o cat --show-cursor -n %d %s 2>/dev/null`, limit, position)
if err != nil {
// 读取失败(含游标因日志轮转失效)则回退检查点并丢弃游标,下一轮按时间窗口重扫
uc.mu.Lock()
uc.sshAt = since
uc.sshCursor = ""
uc.mu.Unlock()
return "", err
}
return raw, nil
}
// sshShouldFire 判断某来源是否已过静默期,并顺带清理过期记录
func (uc *AlertUsecase) sshShouldFire(ip string, now time.Time) bool {
uc.mu.Lock()
@@ -818,16 +841,14 @@ func (uc *AlertUsecase) clearState(ruleID uint) {
delete(uc.hits, key)
}
}
for key := range uc.fired {
for key := range uc.silenced {
if strings.HasPrefix(key, prefix) {
delete(uc.fired, key)
delete(uc.silenced, key)
}
}
}
func (uc *AlertUsecase) buildMessage(rule *AlertRule, metric *AlertMetric) string {
label := uc.metricLabel(rule.Type, metric.Target)
switch rule.Type {
case AlertTypeService:
return uc.t.Get("service %s is not running", metric.Target)
@@ -845,7 +866,7 @@ func (uc *AlertUsecase) buildMessage(rule *AlertRule, metric *AlertMetric) strin
return uc.t.Get("website %s expires in %s days", metric.Target, uc.formatValue(rule.Type, metric.Value))
}
return uc.t.Get("%s is %s, %s threshold %s", label, uc.formatValue(rule.Type, metric.Value), uc.operatorLabel(rule.Operator), uc.formatValue(rule.Type, rule.Threshold))
return uc.t.Get("%s is %s, %s threshold %s", uc.metricLabel(rule.Type, metric.Target), uc.formatValue(rule.Type, metric.Value), uc.operatorLabel(rule.Operator), uc.formatValue(rule.Type, rule.Threshold))
}
func (uc *AlertUsecase) metricLabel(typ, target string) string {
@@ -927,16 +948,48 @@ func (uc *AlertUsecase) formatValue(typ string, value float64) string {
return fmt.Sprintf("%.0f", value)
}
if IsStatusAlert(typ) {
if uc.isStatusType(typ) {
return uc.t.Get("not running")
}
return fmt.Sprintf("%.2f", value)
}
// needsSystemInfo 该规则类型是否依赖 CurrentInfo 采集的系统指标
func (uc *AlertUsecase) needsSystemInfo(typ string) bool {
switch typ {
case AlertTypeCPU, AlertTypeMemory, AlertTypeSwap,
AlertTypeLoad1, AlertTypeLoad5, AlertTypeLoad15,
AlertTypeDisk, AlertTypeDiskInode,
AlertTypeNetIn, AlertTypeNetOut, AlertTypeDiskRead, AlertTypeDiskWrite:
return true
}
return false
}
// isStatusType 状态类指标只有「运行/未运行」两种取值
func (uc *AlertUsecase) isStatusType(typ string) bool {
switch typ {
case AlertTypeService, AlertTypeProject, AlertTypeContainer, AlertTypeApp, AlertTypeDatabase:
return true
}
return false
}
// statusValue 将运行状态转为告警取值,未运行记为 1,配合 normalizeRule 的 >=1 判定
func (uc *AlertUsecase) statusValue(running bool) float64 {
if running {
return 0
}
return 1
}
// normalizeRule 补齐规则的默认值,状态类规则固定为「不在运行」
func normalizeRule(rule *AlertRule) {
if IsStatusAlert(rule.Type) {
func (uc *AlertUsecase) normalizeRule(rule *AlertRule) {
if uc.isStatusType(rule.Type) {
rule.Operator = AlertOperatorGTE
rule.Threshold = 1
}
@@ -945,7 +998,7 @@ func normalizeRule(rule *AlertRule) {
}
}
func matchThreshold(rule *AlertRule, value float64) bool {
func (uc *AlertUsecase) matchThreshold(rule *AlertRule, value float64) bool {
switch rule.Operator {
case AlertOperatorGTE:
return value >= rule.Threshold
@@ -958,27 +1011,6 @@ func matchThreshold(rule *AlertRule, value float64) bool {
}
}
// rateOf 依据上次累计值计算每秒增量,首次采集速率为 0
func rateOf(prev ioSnapshot, in, out uint64, now time.Time) ioSnapshot {
snap := ioSnapshot{in: in, out: out, at: now}
if prev.at.IsZero() {
return snap
}
elapsed := now.Sub(prev.at).Seconds()
if elapsed < 1 {
elapsed = 1
}
if in >= prev.in {
snap.inRate = uint64(float64(in-prev.in) / elapsed)
}
if out >= prev.out {
snap.outRate = uint64(float64(out-prev.out) / elapsed)
}
return snap
}
func stateKey(ruleID uint, target string) string {
func (uc *AlertUsecase) stateKey(ruleID uint, target string) string {
return fmt.Sprintf("%d:%s", ruleID, target)
}
-29
View File
@@ -1,29 +0,0 @@
package biz
import "testing"
// 多域名证书的目标是逗号分隔的域名列表,用其中任一域名都应能命中规则
func TestFilterTargetMatchesAnyCertDomain(t *testing.T) {
metrics := []*AlertMetric{
{Target: "a.example.com,b.example.com", Value: 5},
{Target: "c.example.com", Value: 30},
}
cases := []struct {
target string
want int
}{
{"", 2},
{"a.example.com", 1},
{"b.example.com", 1},
{"c.example.com", 1},
{"a.example.com,b.example.com", 1},
{"missing.example.com", 0},
}
for _, c := range cases {
if got := filterTarget(c.target, metrics); len(got) != c.want {
t.Fatalf("target %q matched %d metrics, want %d", c.target, len(got), c.want)
}
}
}
+7 -4
View File
@@ -71,13 +71,13 @@ type AppUsecase struct {
t *gotext.Locale
}
func NewAppUsecase(t *gotext.Locale, appRepo AppRepo, cacheRepo CacheRepo, taskRepo TaskRepo) (*AppUsecase, error) {
func NewAppUsecase(t *gotext.Locale, appRepo AppRepo, cacheRepo CacheRepo, taskRepo TaskRepo) *AppUsecase {
return &AppUsecase{
repo: appRepo,
cache: cacheRepo,
task: taskRepo,
t: t,
}, nil
}
}
func (uc *AppUsecase) Categories() []types.LV {
@@ -160,10 +160,13 @@ func (uc *AppUsecase) GetHomeShow() ([]map[string]string, error) {
return nil, err
}
// 目录只加载一次
catalog := lo.KeyBy(uc.All(), func(item *api.App) string { return item.Slug })
filtered := make([]map[string]string, 0)
for item := range slices.Values(apps) {
loaded, err := uc.Get(item.Slug)
if err != nil {
loaded, ok := catalog[item.Slug]
if !ok {
continue
}
filtered = append(filtered, map[string]string{
+5 -5
View File
@@ -31,7 +31,7 @@ type BackupRepo interface {
Delete(typ BackupType, name string) error
Restore(typ BackupType, backup, target string) error
ClearExpired(path, prefix string, save uint) error
ClearStorageExpired(account uint, typ BackupType, prefix string, save uint) error
ClearStorageExpired(account uint, dir, prefix string, save uint) error
CutoffLog(path, target string) (string, error)
CutoffUpload(account uint, typ BackupType, name string, files []string) error
GetDefaultPath(typ BackupType) string
@@ -46,13 +46,13 @@ type BackupUsecase struct {
t *gotext.Locale
}
func NewBackupUsecase(notifyUsecase *NotifyUsecase, t *gotext.Locale, log *slog.Logger, backupRepo BackupRepo) (*BackupUsecase, error) {
func NewBackupUsecase(notifyUsecase *NotifyUsecase, t *gotext.Locale, log *slog.Logger, backupRepo BackupRepo) *BackupUsecase {
return &BackupUsecase{
repo: backupRepo,
log: log,
notify: notifyUsecase,
t: t,
}, nil
}
}
func (uc *BackupUsecase) List(typ BackupType) ([]*types.BackupFile, error) {
@@ -113,8 +113,8 @@ func (uc *BackupUsecase) ClearExpired(path, prefix string, save uint) error {
return uc.repo.ClearExpired(path, prefix, save)
}
func (uc *BackupUsecase) ClearStorageExpired(account uint, typ BackupType, prefix string, save uint) error {
return uc.repo.ClearStorageExpired(account, typ, prefix, save)
func (uc *BackupUsecase) ClearStorageExpired(account uint, dir, prefix string, save uint) error {
return uc.repo.ClearStorageExpired(account, dir, prefix, save)
}
func (uc *BackupUsecase) CutoffLog(path, target string) (string, error) {
+2 -2
View File
@@ -145,13 +145,13 @@ type BackupAccountUsecase struct {
log *slog.Logger
}
func NewBackupAccountUsecase(t *gotext.Locale, log *slog.Logger, backupAccountRepo BackupAccountRepo, settingRepo SettingRepo) (*BackupAccountUsecase, error) {
func NewBackupAccountUsecase(t *gotext.Locale, log *slog.Logger, backupAccountRepo BackupAccountRepo, settingRepo SettingRepo) *BackupAccountUsecase {
return &BackupAccountUsecase{
repo: backupAccountRepo,
setting: settingRepo,
t: t,
log: log,
}, nil
}
}
func (uc *BackupAccountUsecase) List(page, limit uint) ([]*BackupStorage, int64, error) {
+1 -1
View File
@@ -14,5 +14,5 @@ var ProviderSet = wire.NewSet(
NewSettingUsecase, NewSSHUsecase, NewTamperUsecase, NewTaskUsecase,
NewTemplateUsecase, NewUserUsecase, NewUserPasskeyUsecase,
NewUserTokenUsecase, NewWebHookUsecase, NewWebsiteUsecase,
NewWebsiteStatUsecase, NewWafUsecase,
NewWebsiteStatUsecase, NewToolboxMigrationUsecase,
)
+78 -48
View File
@@ -3,7 +3,6 @@ package biz
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
"slices"
@@ -12,6 +11,7 @@ import (
"github.com/leonelquinteros/gotext"
mholtacme "github.com/mholt/acmez/v3/acme"
"github.com/samber/lo"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/request"
@@ -23,7 +23,6 @@ import (
type Cert struct {
ID uint `gorm:"primaryKey" json:"id"`
AccountID uint `gorm:"not null;default:0" json:"account_id"` // 关联的 ACME 账户 ID
WebsiteID uint `gorm:"not null;default:0" json:"website_id"` // 关联的网站 ID
DNSID uint `gorm:"not null;default:0" json:"dns_id"` // 关联的 DNS ID
Type string `gorm:"not null;default:''" json:"type"` // 证书类型 (P256, P384, 2048, 3072, 4096)
Domains []string `gorm:"not null;default:'[]';serializer:json" json:"domains"` // 域名
@@ -37,9 +36,16 @@ type Cert struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Website *Website `gorm:"foreignKey:WebsiteID" json:"website"`
Account *CertAccount `gorm:"foreignKey:AccountID" json:"account"`
DNS *CertDNS `gorm:"foreignKey:DNSID" json:"dns"`
Websites []*Website `gorm:"foreignKey:CertID" json:"websites"` // 部署的网站
Account *CertAccount `gorm:"foreignKey:AccountID" json:"account"`
DNS *CertDNS `gorm:"foreignKey:DNSID" json:"dns"`
}
// WebsiteIDs 取证书关联的网站 ID 列表
func (r *Cert) WebsiteIDs() []uint {
return lo.Map(r.Websites, func(website *Website, _ int) uint {
return website.ID
})
}
type CertRepo interface {
@@ -54,10 +60,12 @@ type CertRepo interface {
GetClient(cert *Cert) (*acme.Client, error)
GenerateSelfSigned(domains []string) ([]byte, []byte, error)
RunScript(cert *Cert) error
ObtainPanel(account *CertAccount, ips []string, webServer string) ([]byte, []byte, error)
LoadWebsite(WebsiteID uint) (*Website, error)
ObtainPanel(account *CertAccount, names []string, webServer string) ([]byte, []byte, error)
LoadWebsites(websiteIDs []uint) ([]*Website, error)
BindWebsites(certID uint, websiteIDs []uint) error
HTTPConfs(cert *Cert, webServer string) (map[string]string, []string)
WriteCertFiles(cert *Cert, certPath, keyPath string) error
EnableWebsiteSSL(website *Website, certPath, keyPath, webServer string, tlsVersions []string) error
EnableWebsiteSSL(website *Website, certPath, keyPath, webServer string, tlsVersions []string, listenIPv6 bool) error
ReloadWebserver(webServer string) error
}
@@ -68,13 +76,13 @@ type CertUsecase struct {
log *slog.Logger
}
func NewCertUsecase(t *gotext.Locale, log *slog.Logger, certRepo CertRepo, settingRepo SettingRepo) (*CertUsecase, error) {
func NewCertUsecase(t *gotext.Locale, log *slog.Logger, certRepo CertRepo, settingRepo SettingRepo) *CertUsecase {
return &CertUsecase{
repo: certRepo,
setting: settingRepo,
t: t,
log: log,
}, nil
}
}
func (uc *CertUsecase) List(page, limit uint) ([]*types.CertList, int64, error) {
@@ -85,8 +93,8 @@ func (uc *CertUsecase) Get(id uint) (*Cert, error) {
return uc.repo.Get(id)
}
func (uc *CertUsecase) GetByWebsite(WebsiteID uint) (*Cert, error) {
return uc.repo.GetByWebsite(WebsiteID)
func (uc *CertUsecase) GetByWebsite(websiteID uint) (*Cert, error) {
return uc.repo.GetByWebsite(websiteID)
}
func (uc *CertUsecase) Upload(ctx context.Context, req *request.CertUpload) (*Cert, error) {
@@ -198,7 +206,7 @@ func (uc *CertUsecase) ObtainAutoWithProgressCallback(ctx context.Context, id ui
ProgressCallback: progressCallback,
})
} else {
if cert.Website == nil {
if len(cert.Websites) == 0 {
return nil, errors.New(uc.t.Get("this certificate is not associated with a website and cannot be obtained. You can try to obtain it manually"))
}
hasWildcard := slices.ContainsFunc(cert.Domains, func(d string) bool {
@@ -207,8 +215,8 @@ func (uc *CertUsecase) ObtainAutoWithProgressCallback(ctx context.Context, id ui
if hasWildcard {
return nil, errors.New(uc.t.Get("wildcard domains cannot use HTTP verification"))
}
conf := fmt.Sprintf("%s/sites/%s/config/site/001-acme.conf", app.Root, cert.Website.Name)
client.UseHTTP(conf, webServer)
confs, fallback := uc.repo.HTTPConfs(cert, webServer)
client.UseHTTP(confs, fallback, webServer)
}
report(uc.t.Get("issuing certificate, domains: %s", strings.Join(cert.Domains, ", ")))
@@ -228,9 +236,9 @@ func (uc *CertUsecase) ObtainAutoWithProgressCallback(ctx context.Context, id ui
return nil, err
}
if cert.Website != nil {
if len(cert.Websites) > 0 {
report(uc.t.Get("deploying certificate to website"))
return &ssl, uc.Deploy(cert.ID, cert.WebsiteID, false)
return &ssl, uc.Deploy(cert.ID, cert.WebsiteIDs(), false)
}
if err = uc.repo.RunScript(cert); err != nil {
@@ -240,9 +248,21 @@ func (uc *CertUsecase) ObtainAutoWithProgressCallback(ctx context.Context, id ui
return &ssl, nil
}
func (uc *CertUsecase) ObtainPanel(account *CertAccount, ips []string) ([]byte, []byte, error) {
func (uc *CertUsecase) ObtainPanel(account *CertAccount, domains []string) ([]byte, []byte, error) {
names := domains
if len(names) == 0 {
var err error
names, err = uc.setting.GetSlice(SettingKeyPublicIPs)
if err != nil {
return nil, nil, err
}
if len(names) == 0 {
return nil, nil, errors.New(uc.t.Get("Please set the panel IP in settings first for ACME certificate generation"))
}
}
webServer, _ := uc.setting.Get(SettingKeyWebserver)
return uc.repo.ObtainPanel(account, ips, webServer)
return uc.repo.ObtainPanel(account, names, webServer)
}
func (uc *CertUsecase) ObtainSelfSigned(id uint) error {
@@ -262,8 +282,8 @@ func (uc *CertUsecase) ObtainSelfSigned(id uint) error {
return err
}
if cert.Website != nil {
return uc.Deploy(cert.ID, cert.WebsiteID, false)
if len(cert.Websites) > 0 {
return uc.Deploy(cert.ID, cert.WebsiteIDs(), false)
}
if err = uc.repo.RunScript(cert); err != nil {
@@ -309,17 +329,16 @@ func (uc *CertUsecase) RenewWithProgressCallback(ctx context.Context, id uint, p
ProgressCallback: progressCallback,
})
} else {
if cert.Website == nil {
if len(cert.Websites) == 0 {
return nil, errors.New(uc.t.Get("this certificate is not associated with a website and cannot be obtained. You can try to obtain it manually"))
} else {
for _, domain := range cert.Domains {
if strings.Contains(domain, "*") {
return nil, errors.New(uc.t.Get("wildcard domains cannot use HTTP verification"))
}
}
conf := fmt.Sprintf("%s/sites/%s/config/site/001-acme.conf", app.Root, cert.Website.Name)
client.UseHTTP(conf, webServer)
}
for _, domain := range cert.Domains {
if strings.Contains(domain, "*") {
return nil, errors.New(uc.t.Get("wildcard domains cannot use HTTP verification"))
}
}
confs, fallback := uc.repo.HTTPConfs(cert, webServer)
client.UseHTTP(confs, fallback, webServer)
}
report(uc.t.Get("renewing certificate, domains: %s", strings.Join(cert.Domains, ", ")))
@@ -344,9 +363,9 @@ func (uc *CertUsecase) RenewWithProgressCallback(ctx context.Context, id uint, p
return nil, err
}
if cert.Website != nil {
if len(cert.Websites) > 0 {
report(uc.t.Get("deploying certificate to website"))
return &ssl, uc.Deploy(cert.ID, cert.WebsiteID, false)
return &ssl, uc.Deploy(cert.ID, cert.WebsiteIDs(), false)
}
return &ssl, nil
@@ -382,8 +401,8 @@ func (uc *CertUsecase) RefreshRenewalInfo(id uint) (mholtacme.RenewalInfo, error
return renewInfo, nil
}
func (uc *CertUsecase) Deploy(ID, WebsiteID uint, enableHTTPS bool) error {
cert, err := uc.repo.Get(ID)
func (uc *CertUsecase) Deploy(id uint, websiteIDs []uint, enableHTTPS bool) error {
cert, err := uc.repo.Get(id)
if err != nil {
return err
}
@@ -392,31 +411,42 @@ func (uc *CertUsecase) Deploy(ID, WebsiteID uint, enableHTTPS bool) error {
return errors.New(uc.t.Get("this certificate has not been obtained successfully and cannot be deployed"))
}
website, err := uc.repo.LoadWebsite(WebsiteID)
websites, err := uc.repo.LoadWebsites(websiteIDs)
if err != nil {
return err
}
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
certPath := filepath.Join(configDir, "fullchain.pem")
keyPath := filepath.Join(configDir, "private.key")
if err = uc.repo.WriteCertFiles(cert, certPath, keyPath); err != nil {
// 建立关联,使续签后能自动部署到这些网站
if err = uc.repo.BindWebsites(cert.ID, websiteIDs); err != nil {
return err
}
// 开启 HTTPS
if enableHTTPS && !website.SSL {
// 原 getVhost 首步读 webserver 设置并传播错误,保持该语义
webServer, err := uc.setting.Get(SettingKeyWebserver)
if err != nil {
webServer, webServerErr := uc.setting.Get(SettingKeyWebserver)
tlsVersions, _ := uc.setting.GetSlice(SettingKeyWebsiteTLSVersions)
listenIPv6, err := uc.setting.GetBool(SettingKeyWebsiteListenIPv6, false)
if err != nil {
return err
}
for _, website := range websites {
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
certPath := filepath.Join(configDir, "fullchain.pem")
keyPath := filepath.Join(configDir, "private.key")
if err = uc.repo.WriteCertFiles(cert, certPath, keyPath); err != nil {
return err
}
tlsVersions, _ := uc.setting.GetSlice(SettingKeyWebsiteTLSVersions)
if err = uc.repo.EnableWebsiteSSL(website, certPath, keyPath, webServer, tlsVersions); err != nil {
return err
// 开启 HTTPS
if enableHTTPS && !website.SSL {
if webServerErr != nil {
return webServerErr
}
if err = uc.repo.EnableWebsiteSSL(website, certPath, keyPath, webServer, tlsVersions, listenIPv6); err != nil {
return err
}
}
}
webServer, _ := uc.setting.Get(SettingKeyWebserver)
return uc.repo.ReloadWebserver(webServer)
}
+2 -2
View File
@@ -46,13 +46,13 @@ type CertAccountUsecase struct {
log *slog.Logger
}
func NewCertAccountUsecase(t *gotext.Locale, log *slog.Logger, certAccountRepo CertAccountRepo, userRepo UserRepo) (*CertAccountUsecase, error) {
func NewCertAccountUsecase(t *gotext.Locale, log *slog.Logger, certAccountRepo CertAccountRepo, userRepo UserRepo) *CertAccountUsecase {
return &CertAccountUsecase{
repo: certAccountRepo,
userRepo: userRepo,
t: t,
log: log,
}, nil
}
}
func (uc *CertAccountUsecase) List(page, limit uint) ([]*CertAccount, int64, error) {
+62 -2
View File
@@ -4,7 +4,10 @@ import (
"slices"
"strings"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/docker"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -27,10 +30,17 @@ type ContainerRepo interface {
type ContainerUsecase struct {
repo ContainerRepo
setting SettingRepo
task TaskRepo
t *gotext.Locale
}
func NewContainerUsecase(repo ContainerRepo, setting SettingRepo) *ContainerUsecase {
return &ContainerUsecase{repo: repo, setting: setting}
func NewContainerUsecase(t *gotext.Locale, containerRepo ContainerRepo, settingRepo SettingRepo, taskRepo TaskRepo) *ContainerUsecase {
return &ContainerUsecase{
repo: containerRepo,
setting: settingRepo,
task: taskRepo,
t: t,
}
}
func (uc *ContainerUsecase) ListAll() ([]types.Container, error) {
@@ -62,6 +72,29 @@ func (uc *ContainerUsecase) Create(req *request.ContainerCreate) (string, error)
return uc.repo.Create(sock, req)
}
func (uc *ContainerUsecase) CreateBackground(req *request.ContainerCreate) error {
shell, err := docker.RunShell(containerSock(uc.setting), req)
if err != nil {
return err
}
task := new(Task)
key := ""
if req.Name != "" {
key = "container:create:" + req.Name
}
task.Key = key
target := req.Name
if target == "" {
target = req.Image
}
task.Name = uc.t.Get("Create container %s", target)
task.Status = TaskStatusWaiting
task.Shell = shell
return uc.task.Push(task)
}
// Update 删除旧容器后按新配置重建同名容器
func (uc *ContainerUsecase) Update(id string, req *request.ContainerCreate) (string, error) {
sock := containerSock(uc.setting)
@@ -71,6 +104,33 @@ func (uc *ContainerUsecase) Update(id string, req *request.ContainerCreate) (str
return uc.repo.Create(sock, req)
}
func (uc *ContainerUsecase) UpdateBackground(id string, req *request.ContainerCreate) error {
sock := containerSock(uc.setting)
runShell, err := docker.RunShell(sock, req)
if err != nil {
return err
}
shell := strings.Join([]string{
"set -e",
docker.Command(sock, "image", "inspect", req.Image) + " >/dev/null 2>&1 || " + docker.Command(sock, "pull", req.Image),
docker.Command(sock, "rm", "--force", id),
runShell,
}, "\n")
target := req.Name
if target == "" {
target = req.Image
}
task := new(Task)
task.Key = "container:update:" + id
task.Name = uc.t.Get("Update container %s", target)
task.Status = TaskStatusWaiting
task.Shell = shell
return uc.task.Push(task)
}
func (uc *ContainerUsecase) Remove(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Remove(sock, id)
+28 -2
View File
@@ -1,7 +1,10 @@
package biz
import (
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/docker"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -16,10 +19,17 @@ type ContainerImageRepo interface {
type ContainerImageUsecase struct {
repo ContainerImageRepo
setting SettingRepo
task TaskRepo
t *gotext.Locale
}
func NewContainerImageUsecase(repo ContainerImageRepo, setting SettingRepo) *ContainerImageUsecase {
return &ContainerImageUsecase{repo: repo, setting: setting}
func NewContainerImageUsecase(t *gotext.Locale, containerImageRepo ContainerImageRepo, settingRepo SettingRepo, taskRepo TaskRepo) *ContainerImageUsecase {
return &ContainerImageUsecase{
repo: containerImageRepo,
setting: settingRepo,
task: taskRepo,
t: t,
}
}
func (uc *ContainerImageUsecase) List() ([]types.ContainerImage, error) {
@@ -37,6 +47,22 @@ func (uc *ContainerImageUsecase) Pull(req *request.ContainerImagePull) error {
return uc.repo.Pull(sock, req)
}
func (uc *ContainerImageUsecase) PullBackground(req *request.ContainerImagePull) error {
shell, cancelShell, err := docker.ImagePullShell(containerSock(uc.setting), req)
if err != nil {
return err
}
task := new(Task)
task.Key = "container:image:pull:" + req.Name
task.Name = uc.t.Get("Pull image %s", req.Name)
task.Status = TaskStatusWaiting
task.Shell = shell
task.CancelShell = cancelShell
return uc.task.Push(task)
}
func (uc *ContainerImageUsecase) Remove(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Remove(sock, id)
+2 -2
View File
@@ -48,14 +48,14 @@ type DatabaseUsecase struct {
log *slog.Logger
}
func NewDatabaseUsecase(databaseUserUsecase *DatabaseUserUsecase, t *gotext.Locale, log *slog.Logger, databaseRepo DatabaseRepo, databaseServerRepo DatabaseServerRepo) (*DatabaseUsecase, error) {
func NewDatabaseUsecase(databaseUserUsecase *DatabaseUserUsecase, t *gotext.Locale, log *slog.Logger, databaseRepo DatabaseRepo, databaseServerRepo DatabaseServerRepo) *DatabaseUsecase {
return &DatabaseUsecase{
repo: databaseRepo,
server: databaseServerRepo,
user: databaseUserUsecase,
t: t,
log: log,
}, nil
}
}
func (uc *DatabaseUsecase) List(ctx context.Context, page, limit uint, typ string) ([]*Database, int64, error) {
+2 -2
View File
@@ -92,12 +92,12 @@ type DatabaseServerUsecase struct {
log *slog.Logger
}
func NewDatabaseServerUsecase(t *gotext.Locale, log *slog.Logger, databaseServerRepo DatabaseServerRepo) (*DatabaseServerUsecase, error) {
func NewDatabaseServerUsecase(t *gotext.Locale, log *slog.Logger, databaseServerRepo DatabaseServerRepo) *DatabaseServerUsecase {
return &DatabaseServerUsecase{
repo: databaseServerRepo,
t: t,
log: log,
}, nil
}
}
func (uc *DatabaseServerUsecase) Count() (int64, error) {
+2 -2
View File
@@ -85,12 +85,12 @@ type DatabaseUserUsecase struct {
log *slog.Logger
}
func NewDatabaseUserUsecase(log *slog.Logger, databaseServerRepo DatabaseServerRepo, databaseUserRepo DatabaseUserRepo) (*DatabaseUserUsecase, error) {
func NewDatabaseUserUsecase(log *slog.Logger, databaseServerRepo DatabaseServerRepo, databaseUserRepo DatabaseUserRepo) *DatabaseUserUsecase {
return &DatabaseUserUsecase{
repo: databaseUserRepo,
server: databaseServerRepo,
log: log,
}, nil
}
}
func (uc *DatabaseUserUsecase) Count() (int64, error) {
+2 -2
View File
@@ -27,13 +27,13 @@ type EnvironmentUsecase struct {
t *gotext.Locale
}
func NewEnvironmentUsecase(t *gotext.Locale, cacheRepo CacheRepo, environmentRepo EnvironmentRepo, taskRepo TaskRepo) (*EnvironmentUsecase, error) {
func NewEnvironmentUsecase(t *gotext.Locale, cacheRepo CacheRepo, environmentRepo EnvironmentRepo, taskRepo TaskRepo) *EnvironmentUsecase {
return &EnvironmentUsecase{
repo: environmentRepo,
cache: cacheRepo,
task: taskRepo,
t: t,
}, nil
}
}
func (uc *EnvironmentUsecase) Types() []types.LV {
+2 -2
View File
@@ -33,11 +33,11 @@ type FileShareUsecase struct {
log *slog.Logger
}
func NewFileShareUsecase(log *slog.Logger, fileShareRepo FileShareRepo) (*FileShareUsecase, error) {
func NewFileShareUsecase(log *slog.Logger, fileShareRepo FileShareRepo) *FileShareUsecase {
return &FileShareUsecase{
repo: fileShareRepo,
log: log,
}, nil
}
}
func (uc *FileShareUsecase) List() ([]*FileShare, error) {
+1 -9
View File
@@ -2,12 +2,9 @@ package biz
import (
"context"
"errors"
"fmt"
"strings"
"github.com/spf13/cast"
"gorm.io/gorm"
)
// operatorID 从 context 获取操作员 ID,无法获取时返回 0(系统操作)
@@ -22,11 +19,6 @@ func operatorID(ctx context.Context) uint64 {
return cast.ToUint64(userID)
}
// IsNotFound 判断错误是否为记录不存在
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// containerSock 从设置读取容器 socket 路径,未配置或失败时返回默认值
func containerSock(setting SettingRepo) string {
sock, _ := setting.Get(SettingKeyContainerSock)
@@ -35,7 +27,7 @@ func containerSock(setting SettingRepo) string {
}
// 自动补全 scheme
if !strings.Contains(sock, "://") {
sock = fmt.Sprintf("unix://%s", sock)
sock = "unix://" + sock
}
return sock
}
-1
View File
@@ -31,7 +31,6 @@ const (
OperationTypeMonitor = "monitor"
OperationTypeWebhook = "webhook"
OperationTypeUser = "user"
OperationTypeWaf = "waf"
)
// LogEntry 日志条目
+17 -2
View File
@@ -11,14 +11,17 @@ import (
type Monitor struct {
ID uint `gorm:"primaryKey" json:"id"`
Info types.CurrentInfo `gorm:"not null;default:'{}';serializer:json" json:"info"`
CreatedAt time.Time `json:"created_at"`
Info types.CurrentInfo `gorm:"not null;default:'{}';serializer:zstd" json:"info"`
CreatedAt time.Time `gorm:"index:idx_monitors_created_at" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type MonitorRepo interface {
Create(monitor *Monitor) error
ClearBefore(t time.Time) error
Clear() error
List(start, end time.Time) ([]*Monitor, error)
VacuumDB() error
}
type MonitorUsecase struct {
@@ -75,6 +78,18 @@ func (uc *MonitorUsecase) Clear() error {
return uc.repo.Clear()
}
func (uc *MonitorUsecase) Create(monitor *Monitor) error {
return uc.repo.Create(monitor)
}
func (uc *MonitorUsecase) ClearBefore(t time.Time) error {
return uc.repo.ClearBefore(t)
}
func (uc *MonitorUsecase) List(start, end time.Time) ([]*Monitor, error) {
return uc.repo.List(start, end)
}
func (uc *MonitorUsecase) VacuumDB() error {
return uc.repo.VacuumDB()
}
+16 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/leonelquinteros/gotext"
"github.com/libtnb/utils/crypt"
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/acepanel/panel/v3/internal/app"
@@ -97,14 +98,14 @@ type NotifyUsecase struct {
pending chan struct{}
}
func NewNotifyUsecase(t *gotext.Locale, log *slog.Logger, notifyChannelRepo NotifyChannelRepo, settingRepo SettingRepo) (*NotifyUsecase, error) {
func NewNotifyUsecase(t *gotext.Locale, log *slog.Logger, notifyChannelRepo NotifyChannelRepo, settingRepo SettingRepo) *NotifyUsecase {
return &NotifyUsecase{
repo: notifyChannelRepo,
setting: settingRepo,
log: log,
t: t,
pending: make(chan struct{}, notifyMaxPending),
}, nil
}
}
func (uc *NotifyUsecase) List(page, limit uint) ([]*NotifyChannel, int64, error) {
@@ -239,6 +240,19 @@ func (uc *NotifyUsecase) SendEvent(event NotifyEvent, subject, body string) {
}()
}
// EventEnabled 判断给定事件中是否有已订阅且配好渠道的
// 供调用方在采集前提前跳过,避免为没人接收的通知付出采集开销
func (uc *NotifyUsecase) EventEnabled(events ...NotifyEvent) bool {
setting, err := uc.GetSetting()
if err != nil || len(setting.Channels) == 0 {
return false
}
return lo.SomeBy(events, func(event NotifyEvent) bool {
return slices.Contains(setting.Events, string(event))
})
}
// SendEventSync 同步发送系统事件通知,未订阅该事件或未配置渠道时静默跳过
// 供 CLI 等短生命周期进程使用,异步发送会随进程退出丢失
func (uc *NotifyUsecase) SendEventSync(ctx context.Context, event NotifyEvent, subject, body string) error {
+2 -2
View File
@@ -44,12 +44,12 @@ type ProjectUsecase struct {
t *gotext.Locale
}
func NewProjectUsecase(t *gotext.Locale, log *slog.Logger, projectRepo ProjectRepo) (*ProjectUsecase, error) {
func NewProjectUsecase(t *gotext.Locale, log *slog.Logger, projectRepo ProjectRepo) *ProjectUsecase {
return &ProjectUsecase{
repo: projectRepo,
log: log,
t: t,
}, nil
}
}
func (uc *ProjectUsecase) Count() (int64, error) {
+2 -2
View File
@@ -2,7 +2,7 @@ package biz
import (
"context"
"fmt"
"errors"
"log/slog"
)
@@ -31,7 +31,7 @@ func (uc *SafeUsecase) UpdatePingStatus(ctx context.Context, status bool) error
return err
}
if !running {
return fmt.Errorf("failed to update ping status: firewall is not running")
return errors.New("failed to update ping status: firewall is not running")
}
if err = uc.repo.SetPingStatus(status); err != nil {
+19 -10
View File
@@ -10,19 +10,28 @@ import (
// ScanEvent 扫描事件模型
type ScanEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
SourceIP string `gorm:"not null;uniqueIndex:idx_scan_unique" json:"source_ip"`
Port uint `gorm:"not null;uniqueIndex:idx_scan_unique" json:"port"`
Protocol string `gorm:"not null;default:'tcp';uniqueIndex:idx_scan_unique" json:"protocol"`
Date string `gorm:"not null;uniqueIndex:idx_scan_unique;index:idx_scan_date" json:"date"` // YYYY-MM-DD
SourceID uint `gorm:"not null;uniqueIndex:idx_scan_unique,priority:2" json:"-"`
Port uint `gorm:"not null;uniqueIndex:idx_scan_unique,priority:3" json:"port"`
Protocol string `gorm:"not null;default:'tcp';uniqueIndex:idx_scan_unique,priority:4" json:"protocol"`
Date string `gorm:"not null;uniqueIndex:idx_scan_unique,priority:1" json:"date"` // YYYY-MM-DD
Count uint `gorm:"not null;default:1" json:"count"`
Country string `gorm:"not null;default:''" json:"country"`
Region string `gorm:"not null;default:''" json:"region"`
City string `gorm:"not null;default:''" json:"city"`
ISP string `gorm:"not null;default:''" json:"isp"`
FirstSeen time.Time `gorm:"not null" json:"first_seen"`
LastSeen time.Time `gorm:"not null" json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SourceIP string `gorm:"->;-:migration" json:"source_ip"`
Country string `gorm:"->;-:migration" json:"country"`
Region string `gorm:"->;-:migration" json:"region"`
City string `gorm:"->;-:migration" json:"city"`
ISP string `gorm:"->;-:migration" json:"isp"`
}
// ScanSource 扫描来源模型
type ScanSource struct {
ID uint `gorm:"primaryKey"`
SourceIP string `gorm:"not null;uniqueIndex:idx_scan_sources_ip"`
Country string `gorm:"not null;default:''"`
Region string `gorm:"not null;default:''"`
City string `gorm:"not null;default:''"`
ISP string `gorm:"not null;default:''"`
}
// ScanSummary 扫描汇总
+3 -2
View File
@@ -35,6 +35,7 @@ const (
SettingKeyProjectPath SettingKey = "project_path"
SettingKeyContainerSock SettingKey = "container_sock"
SettingKeyWebsiteTLSVersions SettingKey = "website_tls_versions"
SettingKeyWebsiteListenIPv6 SettingKey = "website_listen_ipv6"
SettingKeyMySQLRootPassword SettingKey = "mysql_root_password"
SettingKeyPostgresPassword SettingKey = "postgres_password"
SettingKeyMongoDBAdminPassword SettingKey = "mongodb_admin_password"
@@ -99,13 +100,13 @@ type SettingUsecase struct {
log *slog.Logger
}
func NewSettingUsecase(t *gotext.Locale, log *slog.Logger, settingRepo SettingRepo, taskRepo TaskRepo) (*SettingUsecase, error) {
func NewSettingUsecase(t *gotext.Locale, log *slog.Logger, settingRepo SettingRepo, taskRepo TaskRepo) *SettingUsecase {
return &SettingUsecase{
repo: settingRepo,
task: taskRepo,
t: t,
log: log,
}, nil
}
}
func (uc *SettingUsecase) Get(key SettingKey, defaultValue ...string) (string, error) {
+19 -9
View File
@@ -31,7 +31,7 @@ type TamperRule struct {
// TamperLog 篡改拦截/告警日志
type TamperLog struct {
ID uint `gorm:"primaryKey" json:"id"`
Path string `gorm:"not null;default:'';index" json:"path"`
Path string `gorm:"not null;default:'';serializer:zstd" json:"path"`
Op string `gorm:"not null;default:''" json:"op"` // write/unlink/rename/setattr/create
PID uint `gorm:"not null;default:0" json:"pid"`
Comm string `gorm:"not null;default:''" json:"comm"`
@@ -69,22 +69,23 @@ type TamperUsecase struct {
log *slog.Logger
t *gotext.Locale
mu sync.Mutex
mgr *tamper.Manager
buf []*TamperLog
bufMu sync.Mutex
notifyAt time.Time
drainC chan struct{}
mu sync.Mutex
mgr *tamper.Manager
cleanedAt time.Time
buf []*TamperLog
bufMu sync.Mutex
notifyAt time.Time
drainC chan struct{}
}
func NewTamperUsecase(notifyUsecase *NotifyUsecase, settingUsecase *SettingUsecase, t *gotext.Locale, log *slog.Logger, tamperRepo TamperRepo) (*TamperUsecase, error) {
func NewTamperUsecase(notifyUsecase *NotifyUsecase, settingUsecase *SettingUsecase, t *gotext.Locale, log *slog.Logger, tamperRepo TamperRepo) *TamperUsecase {
return &TamperUsecase{
repo: tamperRepo,
setting: settingUsecase,
notify: notifyUsecase,
log: log,
t: t,
}, nil
}
}
// Supported 当前平台是否支持防篡改
@@ -312,7 +313,16 @@ func (uc *TamperUsecase) notifyBlocked(logs []*TamperLog) {
}
// CleanupLogs 清理过期日志
// 调用方每分钟触发,但日志按天过期,故限流到 6 小时一次
func (uc *TamperUsecase) CleanupLogs() {
uc.mu.Lock()
if time.Since(uc.cleanedAt) < 6*time.Hour {
uc.mu.Unlock()
return
}
uc.cleanedAt = time.Now()
uc.mu.Unlock()
s, err := uc.GetSetting()
if err != nil || s.LogDays == 0 {
return
+2 -2
View File
@@ -26,12 +26,12 @@ type TemplateUsecase struct {
t *gotext.Locale
}
func NewTemplateUsecase(t *gotext.Locale, cacheRepo CacheRepo, templateRepo TemplateRepo) (*TemplateUsecase, error) {
func NewTemplateUsecase(t *gotext.Locale, cacheRepo CacheRepo, templateRepo TemplateRepo) *TemplateUsecase {
return &TemplateUsecase{
repo: templateRepo,
cache: cacheRepo,
t: t,
}, nil
}
}
// List 获取所有模版,包括本地模板

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