Compare commits

...
194 Commits
Author SHA1 Message Date
Akizon77 0dc80f7696 更新依赖项 2026-01-29 01:17:36 +08:00
Akizon77 212e3c689b feat: 添加网络流量指标支持 2026-01-29 01:05:49 +08:00
Akizon77 488ca451ae feat: RPC 内部请求,MJPEG流 2026-01-29 00:44:19 +08:00
Akizon77 f50c167b37 fix: 当您修改历史数据保存时间相关设置时,需要重新启动服务端才会生效 2026-01-29 00:33:29 +08:00
Akizon77 4e91d62d48 feat: 添加 WAL 检查点优化数据库性能 2025-11-14 16:44:48 +08:00
Akizon a1b0b1e177 Merge pull request #355 from komari-monitor/dev
docs: 添加镜像站链接 https://github.com/komari-monitor/komari-document/issue…
2025-11-09 11:52:36 +08:00
Akizon77 147dd2011a docs: 添加镜像站链接 https://github.com/komari-monitor/komari-document/issues/10 2025-11-09 11:43:05 +08:00
Akizon77 d24b71d4af docs: 移除 ClawRun - 云平台未更新维护且开发者无法更改内容 2025-11-04 23:16:14 +08:00
Akizon 483e3a94e9 remove Claw Run 2025-11-04 22:10:48 +08:00
Akizon e612f5ed8f Add badges for GitHub and DeepWiki to README 2025-11-04 21:38:45 +08:00
github-actions[bot] 78f3c8fd4b Merge pull request #350 from komari-monitor/dev
fix: #344 引入更宽松的认证方式
2025-11-04 06:50:01 +00:00
Akizon77 5a4e073d63 fix: #344 引入更宽松的认证方式 2025-11-04 14:49:34 +08:00
Akizon77 22cbfecfa9 feat: 整合日志功能 2025-11-04 14:36:40 +08:00
github-actions[bot] d1b059fc2f Merge pull request #349 from komari-monitor/dev
feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey)
2025-11-04 05:02:09 +00:00
Akizon cf5c410aa6 Merge branch 'main' into dev 2025-11-04 13:01:53 +08:00
Akizon 9c46673a1a Merge pull request #348 from RSJWY/dev
feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey)
2025-11-04 00:50:10 +08:00
RSJWY 45c27ae981 feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey) 2025-11-03 22:29:55 +08:00
akizon77 b62fb1f708 feat: 添加 JavaScript 消息发送器,支持 fetch 和 XMLHttpRequest API 2025-10-25 20:43:14 +08:00
github-actions[bot] 579c196643 Merge pull request #335 from komari-monitor/dev
feat: 添加 NOTICE 文件,包含版权和许可证信息
2025-10-18 09:16:09 +00:00
Akizon77 e8da6e213a feat: 添加 NOTICE 文件,包含版权和许可证信息 2025-10-18 17:15:20 +08:00
github-actions[bot] d9426e43a9 Merge pull request #333 from komari-monitor/dev
feat: 添加 EULA 接受字段到配置模型
2025-10-17 17:15:38 +00:00
Akizon77 aad0274d56 feat: 添加 EULA 接受字段到配置模型 2025-10-18 00:15:05 +08:00
github-actions[bot] d65f7ca924 Merge pull request #331 from komari-monitor/dev
fix: 重复注册Bark
2025-10-15 05:14:01 +00:00
Akizon77 a4d95640a5 fix: 重复注册Bark 2025-10-15 13:13:31 +08:00
Akizon77 adda3a2488 docs: 移除Crowdin 2025-10-15 13:09:18 +08:00
github-actions[bot] 34654c0969 Merge pull request #320 from komari-monitor/dev
fix: #319 伪随机 token 可预测
2025-10-09 11:09:44 +00:00
Akizon77 b708f9817d fix: 伪随机 token 可预测 2025-10-09 19:09:11 +08:00
github-actions[bot] 1210a1cbd9 Merge pull request #314 from komari-monitor/dev
refactor: 重写备份上传功能
2025-10-06 08:10:07 +00:00
Akizon77 24f1a673b9 refactor: 重写备份上传功能 2025-10-06 16:07:36 +08:00
github-actions[bot] a3162e1992 Merge pull request #313 from komari-monitor/dev
feat: 添加配置项以控制是否向访客发送 IP 地址
2025-10-06 07:31:40 +00:00
Akizon77 17f904e4ab feat: 添加配置项以控制是否向访客发送 IP 地址 2025-10-06 13:52:12 +08:00
github-actions[bot] 1324f4fa21 Merge pull request #312 from komari-monitor/dev
refactor: 重写下载备份功能
2025-10-06 05:06:40 +00:00
Akizon77 cf15144bb9 refactor: 重写下载备份功能 2025-10-06 13:06:01 +08:00
github-actions[bot] 946b27b3b8 Merge pull request #309 from komari-monitor/dev
feat: 启用SQLite的WAL模式
2025-10-03 06:12:48 +00:00
Akizon77 aed5254cf0 feat: 启用SQLite的WAL模式 2025-10-03 14:11:47 +08:00
github-actions[bot] 7b8922b7ac Merge pull request #296 from komari-monitor/dev
feat: 添加自定义脚本连接域名配置
2025-09-26 13:58:45 +00:00
Akizon77 733ad4c6c0 feat: 添加自定义脚本连接域名配置 2025-09-26 21:58:01 +08:00
github-actions[bot] ef98ddd1d3 Merge pull request #295 from komari-monitor/dev
fix: 优化默认通知模板
2025-09-26 13:55:08 +00:00
Akizon77 ca28c40aca fix: 优化默认通知模板 2025-09-26 21:54:34 +08:00
github-actions[bot] 0af5903bfe Merge pull request #283 from komari-monitor/dev
fix: 增强模板替换功能,支持 JSON 内容类型的占位符转义
2025-09-21 14:09:52 +00:00
akizon77 717b7b4a88 fix: 增强模板替换功能,支持 JSON 内容类型的占位符转义 2025-09-21 22:08:54 +08:00
github-actions[bot] 05eec9515b Merge pull request #282 from komari-monitor/dev
fix: 处理QQ发送消息时的短响应错误
2025-09-21 13:42:07 +00:00
akizon77 877852fc29 fix: 处理QQ发送消息时的短响应错误 2025-09-21 21:38:00 +08:00
github-actions[bot] dbb9075a52 Merge pull request #281 from komari-monitor/dev
fix: 实现按任务ID分组的记录比例下采样
2025-09-20 16:43:49 +00:00
akizon77 eafcf7fe76 fix: 实现按任务ID分组的记录比例下采样 2025-09-21 00:43:00 +08:00
github-actions[bot] 094ce6c1ba Merge pull request #280 from komari-monitor/dev
feat: 更新通知机制,使用事件消息替代文本消息
2025-09-20 11:41:25 +00:00
akizon77 2eed2cf7ba fix: 测试消息使用事件 2025-09-20 17:37:22 +08:00
akizon77 88b9d3bdac Merge branch 'main' into dev 2025-09-20 17:22:29 +08:00
Akizon 9c87227613 Merge pull request #269 from kdwycz/dev
新增了GPU监控数据接收存储
2025-09-17 16:53:46 +08:00
Akizon77 bc737b1ef9 feat: 更新通知模板,添加表情和消息内容 2025-09-17 13:50:19 +08:00
Akizon77 911d990690 feat: 更新通知机制,使用事件消息替代文本消息 2025-09-17 13:41:17 +08:00
kdwycz 0f7cd5f438 新增了GPU监控数据接收存储
接收agent发送的gpu监控数据.
参考Record实现, 新增GPURecord表存储监控数据和压缩监控数据.
2025-09-15 20:23:16 +08:00
github-actions[bot] 354903b97b Merge pull request #264 from komari-monitor/dev
fix: 修正 WebSocket 升级逻辑
2025-09-14 05:06:52 +00:00
Akizon77 b432396111 fix: 修正 WebSocket 升级逻辑 2025-09-14 13:06:02 +08:00
github-actions[bot] 491c0ad531 Merge pull request #263 from komari-monitor/dev
feat: 延迟显示更多信息
2025-09-14 02:41:08 +00:00
Akizon77 1540b78487 feat: 延迟显示更多信息 2025-09-14 01:17:51 +00:00
Akizon77 11f43b116d fix: 修正Uptime 2025-09-13 20:05:46 +08:00
github-actions[bot] a0760c8e93 Merge pull request #261 from komari-monitor/dev
feat: rpc2最近信息补全延迟、uptime
2025-09-13 09:30:40 +00:00
github-actions[bot] 85ecfc3500 Merge pull request #260 from komari-monitor/dev
feat: 添加 Nezha 兼容 gRPC 服务器的启动和停止功能
2025-09-13 09:29:10 +00:00
Akizon77 4c2308622a feat: rpc2最近信息补全延迟、uptime 2025-09-13 17:28:04 +08:00
Akizon77 25b6a25f9a fix: 更新 OAuthCallback 重定向路径,从 /manage 改为 /admin 2025-09-13 16:19:51 +08:00
Akizon77 7138c22d02 refactor: 移除不必要的 nezhaBoot 变量,更新 StopNezhaCompat 以强制停止服务器 2025-09-13 15:52:57 +08:00
Akizon77 a06a5c9e72 feat: 替换 StartNezhaCompatServer 为 StartNezhaCompat,标记为已弃用 2025-09-13 15:45:20 +08:00
Akizon77 668eb63668 feat: 添加 Nezha 兼容 gRPC 服务器的启动和停止功能 2025-09-13 15:09:34 +08:00
Akizon77 7d75f1ba37 feat: 在客户端自动创建时记录审计日志事件 2025-09-13 15:01:20 +08:00
github-actions[bot] 2c7b59985e Merge pull request #259 from komari-monitor/dev
feat: 添加数据掩码功能以保护敏感信息
2025-09-13 06:36:17 +00:00
Akizon77 b616eeb2d4 feat: 添加数据掩码功能以保护敏感信息 2025-09-13 14:35:42 +08:00
Akizon77 0f0e359c63 wip: 通知 2025-09-12 03:01:42 +00:00
Akizon77 1b83644788 docs: 更新 Sharon 图片 2025-09-10 04:40:13 +00:00
github-actions[bot] 9a9200744e Merge pull request #250 from komari-monitor/dev
feat: 兼容哪吒Agent
2025-09-09 14:41:47 +00:00
Akizon77 e763bc12c4 docs: 添加 SharonNetworks 的赞助信息 2025-09-09 14:39:39 +00:00
Akizon77 8d7b47a7de feat: 兼容哪吒Agent 2025-09-08 13:38:47 +00:00
github-actions[bot] d7b3c3443d Merge pull request #240 from komari-monitor/dev
feat: 未绑定第三方账户时不能关闭密码登录
2025-09-06 04:03:58 +00:00
Akizon77 8559083d66 feat: 未绑定第三方账户时不能关闭密码登录 2025-09-06 04:02:22 +00:00
github-actions[bot] 96ef3c9fd5 Merge pull request #239 from komari-monitor/dev
feat: 添加JSON-RPC2接口
2025-09-06 03:21:32 +00:00
Akizon77 3cace1fce0 feat: 添加 maxCount 参数以限制记录数量并实现分组下采样 2025-09-06 03:16:18 +00:00
Akizon77 f2adfcc052 feat: 添加获取记录的 RPC 方法及相关逻辑 2025-09-06 03:02:11 +00:00
Akizon77 b31bfa8ba9 feat: 添加获取版本和节点最近状态的方法 2025-09-06 02:44:27 +00:00
Akizon77 b098509df4 feat: 添加 getMe 方法 2025-09-02 14:12:00 +00:00
Akizon77 2df23b6ddb feat: 更新 getNodesLatestStatus 支持通过多个 UUID 获取节点最新状态 2025-08-31 20:44:32 +08:00
Akizon77 6dbfdf7f89 feat: 添加获取节点最新状态的 RPC 方法及相关逻辑 2025-08-31 20:37:46 +08:00
Akizon77 5d8b0d9c91 feat: rpc 添加公开信息方法 2025-08-31 13:15:39 +08:00
Akizon77 fa00885d8b feat: 增强 JSON-RPC 支持,添加批量请求处理及权限管理 2025-08-31 12:47:36 +08:00
Akizon77 f3b52b3282 feat: 添加 JSON-RPC2 支持及相关方法注册 2025-08-30 18:43:36 +08:00
svnmoe 10ddde6592 feat:update /api/records/ping API (#232)
* feat:update /api/records/ping API
Request method:GET
parameters:
  uuid | string | client uuid
  task_id | int | ping_task_id
  hours   | int | ping_record_hours
*At least one of uuid or task_id must be provided
examples:
  GET /api/records/ping?uuid= &hours=
  GET /api/records/ping?task_id= &hours=
  GET /api/records/ping?uuid= &task_id= &hours=\

* fix:remove target in /api/records/ping

* feat:add load_type parameters for api/records
load_type parameters can be:
cpu
gpu
ram
swap
load
temp
disk
network
process
connections
all (or not passing a parameter)
example:
GET /api/records/load?uuid= &load_type=cpu&hours=
GET /api/records/load?uuid= &hours=4
2025-08-30 13:05:12 +08:00
github-actions[bot] 9dff938db3 Merge pull request #229 from komari-monitor/dev
refactor: 简化 CFACCESS OAuth 逻辑
2025-08-28 03:18:56 +00:00
Akizon77 44ae7b218f refactor: 简化 CFACCESS OAuth 逻辑 2025-08-28 11:14:30 +08:00
Akizon77 f6fa61fe56 Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-28 10:39:48 +08:00
xxddff 207c3fa75d Merge pull request #228 from xxddff/cloudflare-access-simply
feat: 添加对 Cloudflare Access 的支持
2025-08-28 10:36:23 +08:00
MoGuQAQ 027c6c8c14 Merge pull request #221 from mogumc/dev
fix: some feature
2025-08-25 12:40:24 +08:00
Akizon 709fafd64a Merge pull request #219 from svnmoe/dev
feat:add GET /api/records/ping?task_id= &hours=
2025-08-24 14:13:17 +08:00
svnmoe 772e43d5b4 feat:add GET /api/records/ping?task_id= &hours= 2025-08-24 14:10:09 +08:00
Akizon 2c0f01be1a Merge pull request #218 from svnmoe/dev
fix:remove target in /api/task/ping
2025-08-24 12:50:55 +08:00
svnmoe f7bec7189c fix:remove target in /api/task/ping 2025-08-24 12:49:25 +08:00
Akizon 1730f849ed Merge pull request #217 from svnmoe/dev
feat:add /api/task/ping to get all PingTask info
2025-08-24 12:43:10 +08:00
svnmoe 1d46194f99 feat:add /api/task/ping to get all PingTask info 2025-08-24 12:40:13 +08:00
Akizon 8d652049af Merge pull request #210 from komari-monitor/copilot/fix-b576a6c5-0ea2-4f16-8483-2eb7767cd8ad
Add PR and manual triggers to binary build workflow
2025-08-22 16:42:44 +08:00
copilot-swe-agent[bot]andAkizon77 7af2a956d2 Add PR and manual triggers to binary build workflow
Co-authored-by: Akizon77 <64686663+Akizon77@users.noreply.github.com>
2025-08-20 14:31:50 +00:00
copilot-swe-agent[bot] 45b23a04c9 Initial plan 2025-08-20 14:26:30 +00:00
github-actions[bot] 1a7c5990ae Merge pull request #209 from komari-monitor/dev
feat: #159 流量提醒
2025-08-20 14:23:53 +00:00
Akizon77 9133dac3d0 feat: #159 流量提醒 2025-08-20 22:09:57 +08:00
Akizon77 2709166a5b feat: 添加流量监控功能,每分钟检查并提醒流量使用情况 2025-08-20 22:01:47 +08:00
github-actions[bot] fcb5284d57 Merge pull request #208 from komari-monitor/dev
feat: 添加登录通知
2025-08-20 11:08:27 +00:00
Akizon77 92d511253f fix: 调整登录通知消息格式 2025-08-20 18:53:55 +08:00
Akizon77 9874c4d339 feat: 添加登录通知 2025-08-20 17:34:28 +08:00
MoGuQAQ 86e97c66b9 Merge pull request #206 from mogumc/main
feat: ws支持请求特定服务器
2025-08-20 16:47:34 +08:00
Akizon f2afed643e ci: Disable auto run 2025-08-18 16:44:06 +08:00
Akizon f502e3df76 Update cleanup-packages.yml 2025-08-18 16:43:10 +08:00
Akizon 7491da96e6 Merge pull request #204 from guodonn01/main
Feat: ws支持快速重连,fix: 精确时间段下发ping task
2025-08-17 20:38:41 +08:00
root 0b887689f0 Feat: ws支持快速重连,fix: 精确时间段下发ping task 2025-08-17 19:59:36 +08:00
Akizon 9cce96c80c Merge pull request #200 from guodonn01/main
Fix: 下发ping task 应该时间更精确得以interval为间隔
2025-08-16 14:49:11 +08:00
gordon 2711d27c03 Fix: 下发ping task 应该时间更精确得以interval为间隔 2025-08-16 13:27:16 +08:00
github-actions[bot] db3f31aba5 Merge pull request #198 from komari-monitor/dev
fix: 确保主题声明回传有值
2025-08-15 15:20:43 +00:00
Akizon77 29a7e3607f fix: 确保主题声明回传有值 2025-08-15 13:46:37 +00:00
Akizon d7f6c2920d Merge pull request #196 from mogumc/main
feat: Agent无ip返回时使用ClientIP
2025-08-14 18:56:09 +08:00
mogumc 6bff7e86f6 feat: Agent无ip返回时使用ClientIP 2025-08-14 18:23:56 +08:00
Akizon a7a78a316e Merge pull request #192 from mogumc/main
fix: 安全数据校验
2025-08-14 15:31:45 +08:00
github-actions[bot] 17ff74854a Merge pull request #195 from komari-monitor/dev
feat: 更新WebSocket连接的跨域检查逻辑
2025-08-14 07:26:38 +00:00
Akizon77 a101601cf4 feat: 更新WebSocket连接的跨域检查逻辑 2025-08-14 07:25:14 +00:00
MoGuQAQ 1904192f55 Merge branch 'komari-monitor:main' into main 2025-08-14 00:57:51 +08:00
github-actions[bot] 7fd49ab55c Merge pull request #193 from komari-monitor/dev
feat: 新增主题配置项、流量限制
2025-08-13 15:10:30 +00:00
Akizon77 3d7138ea30 feat: 管理主题配置
fix: recent鉴权
2025-08-13 15:08:33 +00:00
mogumc 835f8f17ec chore: 删除调试信息 2025-08-13 16:57:31 +08:00
mogumc 488c1afcab fix: 安全数据校验 2025-08-13 16:51:18 +08:00
mogumc 1fd59efe6c fix: 修复未登录可以通过uuid获取流量与ping记录 2025-08-13 15:47:20 +08:00
Akizon 9cf0366aca Merge pull request #191 from mogumc/main
fix: 修复未登录状态可以通过uuid获取隐藏信息
2025-08-13 13:43:01 +08:00
mogumc 15d7480547 fix: 修复未登录状态可以通过uuid获取隐藏信息 2025-08-13 13:37:00 +08:00
Akizon77 819c55d691 feat: 新增流量限制类型字段 2025-08-12 15:02:27 +00:00
Akizon77 67b4c5ebd8 feat: 新增流量限制字段 2025-08-12 13:31:38 +00:00
github-actions[bot] 4bd9fda728 Merge pull request #190 from komari-monitor/dev
feat: 支持隐藏特定服务器
2025-08-12 09:11:10 +00:00
Akizon77 33b8308b3f chore: 更正注释 2025-08-12 09:09:48 +00:00
Akizon77 57cf9c243f feat: 支持隐藏特定服务器
chore: 迁移ws的API接口到api包下
2025-08-12 08:59:01 +00:00
github-actions[bot] f497a1e48b Merge pull request #189 from komari-monitor/dev
fix: 修正createGETRequest方法中的URL替换逻辑
2025-08-12 04:46:17 +00:00
Akizon77 f23fa95a56 fix: 修正createGETRequest方法中的URL替换逻辑 2025-08-12 04:45:20 +00:00
github-actions[bot] 8e8033bb35 Merge pull request #187 from komari-monitor/dev
feat: 支持通过环境变量显式关闭Origin校验
2025-08-10 08:50:53 +00:00
Akizon77 71b0903b0e feat: 支持通过环境变量显式关闭Origin校验 2025-08-10 08:49:23 +00:00
Gil Schneider d0eefe2dd2 feat: 新增聚合登录(QQ)功能 (#185)
* feat:新增单点登录提供商“聚合登录”;
feat:新增集合登录的登录参数设置;

* feat:移除nsmao接口支持。

* 移除调试环境生成的二进制文件
2025-08-10 15:04:49 +08:00
github-actions[bot] 2c547bab68 Merge pull request #186 from komari-monitor/dev
ci: 禁用ghcr缓存
2025-08-10 07:03:45 +00:00
Akizon77 2dc04b5ecf ci: 禁用ghcr缓存 2025-08-10 07:02:48 +00:00
github-actions[bot] e2dae1ec7b Merge pull request #182 from komari-monitor/dev
fix: 修复2FA验证逻辑
2025-08-09 08:36:41 +00:00
Akizon77 59d923e168 fix: 修复2FA验证逻辑 2025-08-09 08:35:46 +00:00
github-actions[bot] 51844c27f1 Merge pull request #181 from komari-monitor/dev
feat: 添加CheckOrigin函数并更新WebSocket连接的源检查逻辑
2025-08-09 07:31:50 +00:00
Akizon77 53171affca feat: 添加CheckOrigin函数并更新WebSocket连接的源检查逻辑 2025-08-09 07:30:44 +00:00
Akizon d31d12e59f Merge commit from fork
fix: WS跨域检查
2025-08-09 14:56:45 +08:00
Akizon77 3715f2b3f9 fix: WS跨域检查 2025-08-09 06:52:39 +00:00
github-actions[bot] cc3d54bff4 Merge pull request #180 from komari-monitor/dev
fix: 修复2FA验证逻辑中的错误处理
2025-08-09 06:40:56 +00:00
Akizon77 ef376a88b8 fix: 修复2FA验证逻辑中的错误处理 2025-08-09 06:39:48 +00:00
github-actions[bot] e639238f0e Merge pull request #179 from komari-monitor/dev
feat: 在删除客户端时同时删除连接的客户端和最新报告
2025-08-09 05:54:31 +00:00
Akizon77 cf8241a77a feat: 在删除客户端时同时删除连接的客户端和最新报告 2025-08-09 05:53:09 +00:00
Akizon77 eb87a4fc33 feat: 计算任务的丢包率 2025-08-09 04:21:34 +00:00
github-actions[bot] 0727fb4343 Merge pull request #178 from komari-monitor/dev
feat: 即使没有ping数据,也返回任务信息
2025-08-09 04:13:08 +00:00
Akizon77 d55d759fec feat: 即使没有ping数据,也返回任务信息 2025-08-09 04:12:10 +00:00
github-actions[bot] aad279ce55 Merge pull request #177 from komari-monitor/dev
feat: 增强数据库备份功能
2025-08-09 04:05:07 +00:00
Akizon77 e9b1a6b2cd feat: 增强数据库备份功能 2025-08-09 04:04:04 +00:00
github-actions[bot] bd5a6934e1 Merge pull request #174 from komari-monitor/dev
feat: 添加自动发现
2025-08-07 05:42:15 +00:00
Akizon77 2f04008453 feat: 添加自动发现 2025-08-07 05:15:21 +00:00
Akizon77 fbaa410fd4 fix: 修改任务结果处理中的时间类型 2025-08-06 16:05:08 +00:00
Akizon77 1d4f6f2814 fix: 移除无用的日志记录以简化API密钥验证 2025-08-06 16:02:53 +00:00
github-actions[bot] ad2f5160fb Merge pull request #173 from komari-monitor/dev
feat: 添加API密钥认证
2025-08-06 15:07:02 +00:00
Akizon77 d284405310 忽略debug文件 2025-08-06 15:05:41 +00:00
Akizon77 ff172ccbf4 feat: 添加API密钥认证 2025-08-06 15:05:27 +00:00
github-actions[bot] 14635948b6 Merge pull request #172 from komari-monitor/dev
fix: GET请求添加占位符
2025-08-06 07:47:48 +00:00
Akizon77 012fd1dcc2 fix: GET请求添加占位符 2025-08-06 07:46:52 +00:00
Akizon77 94fa5862b1 fix: 更新自动合并工作流,移除不必要的注释和步骤 2025-08-06 07:43:30 +00:00
Akizon77 e7ec6c71f7 Merge branch 'main' into dev 2025-08-06 07:43:11 +00:00
Akizon 59b187b2a6 fix: 移除主题更新中的下载链接保存逻辑 (#171) 2025-08-06 07:15:52 +00:00
Akizon77 5e7cb0f9f1 fix: 移除主题更新中的下载链接保存逻辑 2025-08-06 07:13:29 +00:00
Akizon77 dfe51b0dc5 Merge branch 'main' into dev 2025-08-06 07:05:37 +00:00
Akizon 7a4dd4bf66 fix: 安装时多一个嵌套的/data (#170)
* fix: 安装时多一个嵌套的/data

* ci: 添加自动合并

* ci: 申请auto-merge权限
2025-08-06 06:36:49 +00:00
Akizon77 0b34beea59 ci: 申请auto-merge权限 2025-08-06 06:35:50 +00:00
Akizon77 90ee9bc731 ci: 添加自动合并 2025-08-06 06:25:39 +00:00
Akizon77 046f38e7c4 fix: 安装时多一个嵌套的/data 2025-08-06 06:07:23 +00:00
Akizon eb733cea45 Merge pull request #168 from dbedu/main
feat: support theme online update.
2025-08-06 12:39:09 +08:00
Akizon eb68f0c629 Merge pull request #167 from komari-monitor/dev
feat: #162 添加Webhook消息发送功能
2025-08-05 15:36:41 +08:00
Akizon77 1cbcac4a00 fix: 修正拼写错误 2025-08-05 07:35:28 +00:00
Akizon77 2d6d58edab feat: #162 添加Webhook消息发送功能 2025-08-05 07:34:05 +00:00
Akizon77 c01fadbb48 feat: 添加option选项 2025-08-05 07:26:11 +00:00
Akizon77 41ef2e239d fix: 更新任务结果保存逻辑以使用模型中的时间类型 2025-08-05 06:44:24 +00:00
Akizon77 7176180343 fix: 修复任务创建时未包含离线客户端 2025-08-05 06:32:45 +00:00
Akizon77 0b9a01c8ac fix: 更新Exec函数以处理离线客户端并保存任务结果 2025-08-05 05:56:00 +00:00
Akizon 50ef62a64a Merge pull request #163 from imlonghao/fix/oidc
fix(oidc): 修正请求 token 阶段的请求包
2025-08-05 13:17:30 +08:00
Akizon 384fa4bbe1 Merge pull request #165 from JohnsonRan/bark
feat: add Bark notification provider
2025-08-04 22:36:42 +08:00
JohnsonRan 10f84a8052 feat: add Bark notification provider 2025-08-04 22:09:49 +08:00
imlonghao 53bd83af8b fix(oidc): 修正请求 token 阶段的请求包 2025-08-04 21:22:20 +08:00
dbedu 53577e21e7 feat: support update theme from github release 2025-08-04 21:01:27 +08:00
dbedu 46c0cb9bfb feat: support update theme from github release 2025-08-04 20:48:10 +08:00
dbedu ac8ee8f9fa feat: add theme online update 2025-08-04 19:56:43 +08:00
Akizon 787e8bdd80 Merge pull request #160 from imlonghao/fix/oidc-redirect-uri
fix(oidc): 增加 redirect_uri
2025-08-03 21:48:47 +08:00
Akizon 83a39e3143 Merge pull request #161 from imlonghao/fix/oidc-reload-config
fix(oidc): 修复配置无法更新的问题
2025-08-03 21:43:21 +08:00
imlonghao 5e2ed79929 fix(oidc): 修复配置无法更新的问题 2025-08-03 21:40:54 +08:00
imlonghao c8f0ba926d fix(oidc): 增加 redirect_uri 2025-08-03 21:22:37 +08:00
Akizon77 17e0e14aef fix: 初次启动时无法加载oauth 2025-08-03 09:54:07 +00:00
Akizon77 731602a80d Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-03 09:42:55 +00:00
Akizon77 37f0e449de fix: 优化OIDC提供者加载逻辑,添加空配置处理 2025-08-03 09:42:52 +00:00
119 changed files with 11778 additions and 775 deletions

No files matched your search

@@ -0,0 +1,109 @@
name: Auto Merge Dev to Main
on:
pull_request:
types: [opened, synchronize] # No longer need 'closed' trigger
branches:
- main
paths-ignore:
- '**.md'
- 'docs/**'
jobs:
# Job to enable Auto-Merge for the PR
auto-merge:
runs-on: ubuntu-latest
# Only run for open PRs from dev to main
if: github.head_ref == 'dev' && github.base_ref == 'main'
permissions:
contents: write
pull-requests: write
checks: read
statuses: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Required for git history
- name: Configure Git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
- name: Get PR number
id: get-pr
run: |
echo "pr_number=${{ github.event.number }}" >> $GITHUB_OUTPUT
- name: Check for conflicts
id: check-conflicts
run: |
# Ensure local branches are up-to-date
git fetch origin main
git fetch origin dev
# Use merge-base to find the common ancestor and check for conflicts
if git merge-tree $(git merge-base origin/main origin/dev) origin/main origin/dev | grep -q '<<<<<<<'; then
echo "has_conflicts=true" >> $GITHUB_OUTPUT
echo "❌ Merge conflicts detected"
else
echo "has_conflicts=false" >> $GITHUB_OUTPUT
echo "✅ No merge conflicts"
fi
- name: Add conflict comment
if: steps.check-conflicts.outputs.has_conflicts == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
--body "❌ **Merge Conflict Detection**
This PR has merge conflicts and cannot be automatically merged. Please resolve conflicts manually and push to the dev branch.
Steps to resolve conflicts:
1. \`git checkout dev\`
2. \`git merge main\`
3. Resolve conflicts
4. \`git commit\`
5. \`git push origin dev\`"
exit 1 # Exit to prevent further steps
- name: Enable auto-merge
if: steps.check-conflicts.outputs.has_conflicts == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Enable auto-merge using a merge commit
gh pr merge ${{ steps.get-pr.outputs.pr_number }} \
--auto \
--merge \
--delete-branch=false
# Add success comment
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
--body "✅ **Auto-merge Enabled**
This PR has no merge conflicts and auto-merge has been enabled. It will be automatically merged to the main branch once all checks pass.
Merge method: Merge commit
Branch retention: dev branch will be preserved"
- name: Summary
run: |
echo "### 🤖 Auto-merge Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- 📝 Processing PR #${{ steps.get-pr.outputs.pr_number }} (dev → main)" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.check-conflicts.outputs.has_conflicts }}" == "true" ]; then
echo "- ❌ Merge conflicts detected, manual resolution required" >> $GITHUB_STEP_SUMMARY
else
echo "- ✅ No merge conflicts, auto-merge enabled" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "**PR Link**: https://github.com/${{ github.repository }}/pull/${{ steps.get-pr.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY
+5 -1
View File
@@ -1,9 +1,13 @@
name: Build Binaries on Main Push
name: Build Binaries on Main Push and PR
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
build-binaries:
+4 -3
View File
@@ -1,8 +1,9 @@
name: Cleanup Old Packages
on:
schedule:
- cron: '0 0 * * 0' # Run every Sunday at midnight
#schedule:
# - cron: '0 0 * * 0' # Run every Sunday at midnight
# 会清理掉正常的镜像
workflow_dispatch: # Allow manual trigger
jobs:
@@ -57,4 +58,4 @@ jobs:
package_version_id: version.id
});
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
name: Publish Docker Image on Main
on:
push:
branches:
- main
#push:
# branches:
# - main
workflow_dispatch:
env:
+2
View File
@@ -96,3 +96,5 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
+5
View File
@@ -7,12 +7,14 @@
*.dll
*.so
*.dylib
__debug*
# local development data
komari.db
/data
/utils/geoip/data
.vscode/
/backup
# Test binary, built with `go test -c`
*.test
@@ -29,3 +31,6 @@ go.work.sum
# env file
.env
# build
komari
+27
View File
@@ -0,0 +1,27 @@
CoreOS Project
Copyright 2014 CoreOS, Inc
This product includes software developed at CoreOS, Inc.
(http://www.coreos.com/).
otp
Copyright (c) 2014, Paul Querna
This product includes software developed by
Paul Querna (http://paul.querna.org/).
Copyright 2014 gRPC authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+19 -9
View File
@@ -1,6 +1,8 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/komari-monitor/komari)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
@@ -8,7 +10,7 @@
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
[Documentation](https://komari-document.pages.dev/) | [Telegram Group](https://t.me/komari_monitor)
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
## Features
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
@@ -22,9 +24,6 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/month
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. Use the One-click Install Script
Suitable for distributions using systemd (Ubuntu, Debian...).
@@ -100,8 +99,6 @@ sudo ./install-komari.sh
## Frontend Development Guide
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
[Translate Komari on Crowdin](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## Client Agent Development Guide
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
@@ -109,9 +106,22 @@ sudo ./install-komari.sh
Issues and Pull Requests are welcome!
## Acknowledgements
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- Thanks to myself for being so idle
- All the developers who submitted PRs and created themes
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 Sponsored by SharonNetworks
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
### The open source software community
All the developers who submitted PRs and created themes
## Star History
+8 -1
View File
@@ -10,7 +10,14 @@ import (
func AdminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
// session-based authentication
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
+37 -1
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
@@ -36,6 +37,7 @@ var TerminalSessions = make(map[string]*TerminalSession)
func SaveClientReportToDB() error {
lastMinute := time.Now().Add(-time.Minute).Unix()
var records []models.Record
var gpuRecords []models.GPURecord
// 遍历所有客户端记录
for uuid, x := range Records.Items() {
@@ -64,10 +66,16 @@ func SaveClientReportToDB() error {
if len(filtered) > 0 {
r := utils.AverageReport(uuid, time.Now(), filtered, 0.3)
records = append(records, r)
// 使用与其他数据相同的聚合逻辑处理GPU数据
gpuAggregated := utils.AverageGPUReports(uuid, time.Now(), filtered, 0.3)
gpuRecords = append(gpuRecords, gpuAggregated...)
}
}
// 批量插入数据库前去重(client与time共同构成唯一键)
db := dbcore.GetDBInstance()
if len(records) > 0 {
unique := make(map[string]models.Record)
for _, rec := range records {
@@ -78,13 +86,30 @@ func SaveClientReportToDB() error {
for _, rec := range unique {
deduped = append(deduped, rec)
}
db := dbcore.GetDBInstance()
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
log.Printf("Failed to save records to database: %v", err)
return err
}
}
// 批量插入GPU记录
if len(gpuRecords) > 0 {
// GPU记录也需要去重,防止重复插入
gpuUnique := make(map[string]models.GPURecord)
for _, rec := range gpuRecords {
key := rec.Client + "_" + strconv.Itoa(rec.DeviceIndex) + "_" + strconv.FormatInt(rec.Time.ToTime().Unix(), 10)
gpuUnique[key] = rec
}
var gpuDeduped []models.GPURecord
for _, rec := range gpuUnique {
gpuDeduped = append(gpuDeduped, rec)
}
if err := db.Model(&models.GPURecord{}).Create(&gpuDeduped).Error; err != nil {
log.Printf("Failed to save GPU records to database: %v", err)
return err
}
}
return nil
}
@@ -119,3 +144,14 @@ func GetVersion(c *gin.Context) {
"hash": utils.VersionHash,
})
}
func isApiKeyValid(apiKey string) bool {
cfg, err := config.Get()
if err != nil {
return false
}
if cfg.ApiKey == "" || len(cfg.ApiKey) < 12 {
return false
}
return apiKey == "Bearer "+cfg.ApiKey
}
+8
View File
@@ -18,6 +18,7 @@ var (
"/api/oauth",
"/api/oauth_callback",
"/api/version",
"/api/recent",
"/api/admin", // 由AdminAuthMiddleware处理
"/api/clients/", // 由TokenAuthMiddleware处理
}
@@ -25,6 +26,13 @@ var (
func PrivateSiteMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
// 如果是公开的路径,直接放行
for _, path := range publicPaths {
if len(c.Request.URL.Path) >= len(path) && c.Request.URL.Path[:len(path)] == path {
+8
View File
@@ -16,6 +16,14 @@ import (
// TokenAuthMiddleware creates a Gin middleware that validates a token from query parameters or request body.
func TokenAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
var token string
// Step 1: Check query parameter for token
+1
View File
@@ -68,6 +68,7 @@ func RemoveClient(c *gin.Context) {
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
c.JSON(200, gin.H{"status": "success"})
ws.DeleteConnectedClients(uuid)
ws.DeleteLatestReport(uuid)
}
+142 -89
View File
@@ -4,7 +4,6 @@ import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
@@ -14,54 +13,170 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
)
// copyFile 复制单个文件到目标路径(会确保父目录存在)
func copyFile(srcPath, destPath string) error {
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return fmt.Errorf("failed to create parent directory: %v", err)
}
src, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file: %v", err)
}
defer src.Close()
dest, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create destination file: %v", err)
}
defer dest.Close()
if _, err = io.Copy(dest, src); err != nil {
return fmt.Errorf("failed to copy file: %v", err)
}
return nil
}
// copyDataToTempExcludingDB 将 ./data 下除了 .db/.db-wal/.db-shm 之外的所有文件复制到临时目录
func copyDataToTempExcludingDB(tempDir string) error {
dataRoot := "./data"
// 如果 data 目录不存在,视为无文件可复制
if stat, err := os.Stat(dataRoot); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("stat data dir: %v", err)
} else if !stat.IsDir() {
return fmt.Errorf("%s is not a directory", dataRoot)
}
return filepath.Walk(dataRoot, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(dataRoot, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
// 跳过数据库相关文件
name := info.Name()
if strings.HasSuffix(strings.ToLower(name), ".db") ||
strings.HasSuffix(strings.ToLower(name), ".db-wal") ||
strings.HasSuffix(strings.ToLower(name), ".db-shm") {
return nil
}
dst := filepath.Join(tempDir, rel)
if info.IsDir() {
return os.MkdirAll(dst, 0o755)
}
return copyFile(p, dst)
})
}
// backupSQLiteTo 使用 SQLite VACUUM INTO 将当前数据库一致性备份到指定路径
func backupSQLiteTo(destDBPath string) error {
if err := os.MkdirAll(filepath.Dir(destDBPath), 0o755); err != nil {
return fmt.Errorf("failed to create parent directory for db: %v", err)
}
db := dbcore.GetDBInstance()
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database connection: %v", err)
}
safePath := strings.ReplaceAll(destDBPath, "'", "''")
vacuumSQL := fmt.Sprintf("VACUUM INTO '%s'", safePath)
if _, err = sqlDB.Exec(vacuumSQL); err != nil {
return fmt.Errorf("sqlite VACUUM INTO failed: %v", err)
}
return nil
}
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
func DownloadBackup(c *gin.Context) {
backupFileName := fmt.Sprintf("backup-%s.zip", time.Now().Format("20060102-150405"))
// 1) 创建临时目录
tempDir, err := os.MkdirTemp("", "komari-backup-*")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
return
}
defer os.RemoveAll(tempDir)
// 2) 复制 ./data 下除 .db/.db-wal/.db-shm 外的所有文件到临时目录
if err := copyDataToTempExcludingDB(tempDir); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying data to temp: %v", err))
return
}
// 3) 处理数据库备份 -> 临时目录/komari.db
destDB := filepath.Join(tempDir, "komari.db")
dbFilePath := flags.DatabaseFile
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
if err := backupSQLiteTo(destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
return
}
} else if dbFilePath != "" {
// 非 sqlite 的情况:若配置了文件路径且存在,则直接复制(按用户需求仍然将名称固定为 komari.db)
if _, err := os.Stat(dbFilePath); err == nil {
if err := copyFile(dbFilePath, destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
return
}
} else if !os.IsNotExist(err) {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
}
// 4) 开始写出 ZIP(以临时目录为根)
backupFileName := fmt.Sprintf("backup-%d.zip", time.Now().UnixMicro())
c.Writer.Header().Set("Content-Type", "application/zip")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
zipWriter := zip.NewWriter(c.Writer)
defer zipWriter.Close()
err := filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
// 写入临时目录里的内容
err = filepath.Walk(tempDir, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 获取在 zip 包内的相对路径
relPath, err := filepath.Rel("./data", filePath)
rel, err := filepath.Rel(tempDir, p)
if err != nil {
return err
}
// 跳过根目录本身,只打包内容
if relPath == "." {
if rel == "." {
return nil
}
// zip 内路径统一用正斜杠
zipPath := filepath.ToSlash(relPath)
// zip 内路径统一正斜杠
zipPath := filepath.ToSlash(rel)
if info.IsDir() {
// 在 zip 中创建目录项
_, err = zipWriter.CreateHeader(&zip.FileHeader{
_, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath + "/",
Method: zip.Deflate, // zip.Store 不压缩
Method: zip.Deflate,
Modified: info.ModTime(),
})
return err
}
// 普通文件,写入 zip
file, err := os.Open(filePath)
f, err := os.Open(p)
if err != nil {
return err
}
defer file.Close()
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
defer f.Close()
w, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath,
Method: zip.Deflate,
Modified: info.ModTime(),
@@ -69,74 +184,16 @@ func DownloadBackup(c *gin.Context) {
if err != nil {
return err
}
_, err = io.Copy(writer, file)
_, err = io.Copy(w, f)
return err
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving data folder: %v", err))
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
return
}
// 获取 ./data 和数据库文件的绝对路径以进行可靠的比较
dataAbsPath, err := filepath.Abs("./data")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error getting absolute path for data directory: %v", err))
return
}
dbFilePath := flags.DatabaseFile
dbAbsPath, err := filepath.Abs(dbFilePath)
if err != nil {
// 如果无法解析数据库路径,可能是一个配置问题,但我们记录并跳过,避免备份失败
log.Printf("Could not determine absolute path for database file '%s', skipping explicit addition. Error: %v\n", dbFilePath, err)
return
}
// 如果数据库文件的绝对路径不是以 ./data 目录的绝对路径开头,则单独添加它
if !strings.HasPrefix(dbAbsPath, dataAbsPath) {
dbFileName := filepath.Base(dbFilePath)
dbInfo, err := os.Stat(dbFilePath)
if err != nil {
if os.IsNotExist(err) {
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
return // 数据库不存在是正常情况,直接返回
}
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
if !dbInfo.IsDir() {
file, err := os.Open(dbFilePath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening database file: %v", err))
return
}
defer file.Close()
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: dbFileName,
Method: zip.Deflate,
Modified: dbInfo.ModTime(),
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating zip entry for database file: %v", err))
return
}
_, err = io.Copy(writer, file)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing database file to zip: %v", err))
return
}
}
} else {
log.Printf("Database file '%s' is within './data', skipping explicit addition.\n", dbFilePath)
}
// 添加备份标记文件
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format("2006-01-02 15:04:05")
// 5) 追加备份标记文件(放在 zip 根目录)
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format(time.RFC3339)
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: "komari-backup-markup",
Method: zip.Deflate,
@@ -146,12 +203,8 @@ func DownloadBackup(c *gin.Context) {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
return
}
_, err = markupWriter.Write([]byte(markupContent))
if err != nil {
if _, err = markupWriter.Write([]byte(markupContent)); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
return
}
// zipWriter.Close() 由 defer 自动调用
}
+32 -12
View File
@@ -2,11 +2,13 @@ package admin
import (
"encoding/json"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
@@ -21,16 +23,25 @@ func Exec(c *gin.Context) {
Clients []string `json:"clients" binding:"required"`
}
var onlineClients []string
var offlineClients []string
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
for uuid := range ws.GetConnectedClients() {
if contain(req.Clients, uuid) {
// for uuid := range ws.GetConnectedClients() {
// if contain(req.Clients, uuid) {
// onlineClients = append(onlineClients, uuid)
// }
// // else {
// // api.RespondError(c, 400, "Client not connected: "+uuid)
// // return
// // }
// }
for _, uuid := range req.Clients {
if client := ws.GetConnectedClients()[uuid]; client != nil {
onlineClients = append(onlineClients, uuid)
} else {
api.RespondError(c, 400, "Client not connected: "+uuid)
return
offlineClients = append(offlineClients, uuid)
}
}
if len(onlineClients) == 0 {
@@ -38,7 +49,7 @@ func Exec(c *gin.Context) {
return
}
taskId := utils.GenerateRandomString(16)
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
api.RespondError(c, 500, "Failed to create task: "+err.Error())
return
}
@@ -55,7 +66,10 @@ func Exec(c *gin.Context) {
payload, _ := json.Marshal(send)
client := ws.GetConnectedClients()[uuid]
if client != nil {
client.WriteMessage(websocket.TextMessage, payload)
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
api.RespondError(c, 400, "Client connection is broke: "+uuid)
return
}
} else {
api.RespondError(c, 400, "Client connection is null: "+uuid)
return
@@ -67,12 +81,18 @@ func Exec(c *gin.Context) {
"task_id": taskId,
"clients": onlineClients,
})
}
func contain(clients []string, uuid string) bool {
for _, client := range clients {
if client == uuid {
return true
if len(offlineClients) > 0 {
for _, uuid := range offlineClients {
tasks.SaveTaskResult(taskId, uuid, "Client offline!", -1, models.FromTime(time.Now()))
}
}
return false
}
// func contain(clients []string, uuid string) bool {
// for _, client := range clients {
// if client == uuid {
// return true
// }
// }
// return false
// }
+1
View File
@@ -75,6 +75,7 @@ func SetOidcProvider(c *gin.Context) {
api.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
return
}
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
return
+5 -2
View File
@@ -6,13 +6,16 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
)
func TestSendMessage(c *gin.Context) {
message := "Komari test message."
err := messageSender.SendTextMessage(message, "Komari Test Notification")
err := messageSender.SendEvent(models.EventMessage{
Event: "Test",
Message: "This is a test message from Komari.",
})
if err != nil {
api.RespondError(c, 500, "Failed to send message: "+err.Error())
return
+336
View File
@@ -3,9 +3,11 @@ package admin
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -13,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
@@ -281,3 +284,336 @@ func isValidThemeShort(short string) bool {
return true
}
// downloadThemeFromURL 从URL下载主题文件
func downloadThemeFromURL(url string) ([]byte, error) {
// 发送HTTP GET请求
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("下载主题文件失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载主题文件失败,HTTP状态码: %d", resp.StatusCode)
}
// 读取响应内容
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取主题文件内容失败: %v", err)
}
// 检查文件大小
if len(data) == 0 {
return nil, errors.New("下载的主题文件为空")
}
return data, nil
}
// getGitHubReleaseDownloadURL 从GitHub API获取最新release的下载链接
// 该函数通过GitHub API获取指定仓库最新release的资源下载链接
// 参考API: https://api.github.com/repos/{owner}/{repo}/releases/latest
// 参数:
// - owner: GitHub仓库所有者
// - repo: GitHub仓库名称
//
// 返回:
// - 最新release的第一个资源的下载链接
// - 错误信息(如果有)
func getGitHubReleaseDownloadURL(owner, repo string) (string, error) {
if owner == "" || repo == "" {
return "", errors.New("GitHub仓库所有者和仓库名称不能为空")
}
// 构建GitHub API URL
// 使用GitHub API获取最新release信息
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
// 发送HTTP GET请求
resp, err := http.Get(apiURL)
if err != nil {
return "", fmt.Errorf("获取GitHub release信息失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("获取GitHub release信息失败,HTTP状态码: %d", resp.StatusCode)
}
// 解析JSON响应
// GitHub API返回的JSON包含assets数组,每个asset包含browser_download_url字段
var releaseInfo struct {
Assets []struct {
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&releaseInfo); err != nil {
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
}
// 检查是否有可下载的资源
if len(releaseInfo.Assets) == 0 {
return "", errors.New("GitHub release中没有可下载的资源")
}
// 返回第一个资源的下载链接
// 相当于shell命令: curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url"
return releaseInfo.Assets[0].BrowserDownloadURL, nil
}
// isGitHubRepoURL 检查URL是否是GitHub仓库地址
// 支持的格式:
// - https://github.com/owner/repo
// - https://github.com/owner/repo.git
// - https://www.github.com/owner/repo
// - http://github.com/owner/repo
// 返回:
// - 是否是GitHub仓库URL
// - 仓库所有者
// - 仓库名称
func isGitHubRepoURL(urlStr string) (bool, string, string) {
if urlStr == "" {
return false, "", ""
}
// 检查URL是否包含github.com
if !strings.Contains(strings.ToLower(urlStr), "github.com") {
return false, "", ""
}
// 解析URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
return false, "", ""
}
// 检查主机名是否是github.com或www.github.com
hostname := strings.ToLower(parsedURL.Host)
if hostname != "github.com" && hostname != "www.github.com" {
return false, "", ""
}
// 解析路径部分,提取owner和repo
// 路径格式应该是 /owner/repo 或 /owner/repo.git
path := strings.TrimPrefix(parsedURL.Path, "/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
return false, "", ""
}
owner := parts[0]
repo := parts[1]
// 如果repo以.git结尾,去掉这个后缀
repo = strings.TrimSuffix(repo, ".git")
return true, owner, repo
}
// UpdateTheme 更新主题
// 支持四种更新方式:
// 1. 使用主题原有URL下载更新
// 2. 提供新的直接下载URL进行更新
// 3. 提供GitHub仓库信息,从最新release下载更新
// 4. 如果主题URL是GitHub仓库地址,自动获取最新release
func UpdateTheme(c *gin.Context) {
var req struct {
Short string `json:"short" binding:"required"` // 主题短名称
URL string `json:"url"` // 新的URL地址(可选)
GitOwner string `json:"git_owner"` // GitHub仓库所有者(可选)
GitRepo string `json:"git_repo"` // GitHub仓库名称(可选)
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
// 检查主题是否存在
themeDir := filepath.Join("./data/theme", req.Short)
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
// 加载现有主题配置
themeInfo, err := loadThemeConfig(themeConfigPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
return
}
// 方式1和方式4: 尝试从原始URL下载主题
// 如果原始URL是GitHub仓库地址,则自动获取最新release
var themeData []byte
// 不保存下载链接,更新后由主题覆盖
//var downloadURL string
// var err2 error
if themeInfo.URL != "" {
// 检查原始URL是否是GitHub仓库地址
// 例如: https://github.com/owner/repo
isGitHub, owner, repo := isGitHubRepoURL(themeInfo.URL)
if isGitHub {
// 方式4: 如果原始URL是GitHub仓库地址,自动获取最新release
// 这是本次需求的核心功能:当主题文件中现有的url地址如果是github仓库的路径,则直接引用该url地址去下载最新的release
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
if err == nil {
// 使用获取到的GitHub release下载链接下载主题
themeData, _ = downloadThemeFromURL(gitHubURL)
//if err2 == nil {
// 注意:这里我们保存的是release的下载链接,而不是GitHub仓库地址
// 这样做是为了在下载成功后,将这个具体的release下载链接保存到主题配置中
// 但在下次更新时,我们仍然会检测到这是一个GitHub仓库,并获取最新的release
// downloadURL = gitHubURL
//}
}
} else {
// 原始URL不是GitHub仓库地址,直接尝试下载(方式1)
themeData, _ = downloadThemeFromURL(themeInfo.URL)
//if err2 == nil {
// downloadURL = themeInfo.URL
//}
}
}
// 如果原始URL下载失败,尝试其他方式下载
if themeData == nil || len(themeData) == 0 {
// 方式3: 如果提供了GitHub仓库信息,尝试从GitHub最新release下载
// 这种方式允许用户只需提供owner和repo信息,系统会自动获取最新release的下载链接
if req.GitOwner != "" && req.GitRepo != "" {
// 从GitHub API获取下载链接
// 相当于: DOWNLOAD_URL=$(curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url")
gitHubURL, err := getGitHubReleaseDownloadURL(req.GitOwner, req.GitRepo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
// 使用获取到的链接下载主题
themeData, err = downloadThemeFromURL(gitHubURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
return
}
// 保存下载链接,稍后更新到主题配置中
// downloadURL = gitHubURL
} else if req.URL != "" {
// 方式2: 如果提供了新URL,尝试从新URL下载
// 检查新URL是否是GitHub仓库地址
isGitHub, owner, repo := isGitHubRepoURL(req.URL)
if isGitHub {
// 如果新URL是GitHub仓库地址,获取最新release
// 这里也应用了自动检测GitHub仓库并下载最新release的功能
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
// 使用获取到的链接下载主题
themeData, err = downloadThemeFromURL(gitHubURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
return
}
// 保存GitHub仓库URL,而不是release下载链接,以便将来可以获取最新版本
// 这是一个重要的设计决策:我们保存的是GitHub仓库URL,而不是具体的release下载链接
// 这样在下次更新时,系统会再次检测到这是GitHub仓库,并自动获取最新的release
// downloadURL = req.URL
} else {
// 新URL不是GitHub仓库地址,直接尝试下载
themeData, err = downloadThemeFromURL(req.URL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
return
}
// downloadURL = req.URL
}
}
}
// 如果没有成功下载主题数据
if themeData == nil || len(themeData) == 0 {
api.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
return
}
// 到这里,我们已经成功获取了主题数据,可能是通过以下四种方式之一:
// 1. 原始URL直接下载
// 2. 原始URL是GitHub仓库,自动获取最新release下载
// 3. 用户提供的新URL下载
// 4. 用户提供的GitHub仓库信息,获取最新release下载
// 临时文件名
tempFile := filepath.Join(os.TempDir(), "downloaded_theme.zip")
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
// 解压ZIP文件并验证
updatedThemeInfo, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
// 如果下载URL与原始URL不同,更新主题配置中的URL
// if downloadURL != themeInfo.URL {
// updatedThemeInfo.URL = downloadURL
// // 更新主题配置文件
// updatedConfigPath := filepath.Join("./data/theme", updatedThemeInfo.Short, "komari-theme.json")
// updatedConfigData, err := json.MarshalIndent(updatedThemeInfo, "", " ")
// if err != nil {
// api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
// return
// }
// if err := os.WriteFile(updatedConfigPath, updatedConfigData, 0644); err != nil {
// api.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
// return
// }
// }
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
}
func UpdateThemeSettings(c *gin.Context) {
theme := c.Query("theme")
if theme == "" || theme == "default" {
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空或不能是默认主题")
return
}
var req map[string]any
err := c.ShouldBindJSON(&req)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
db := dbcore.GetDBInstance()
data, err := json.Marshal(&req)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
return
}
var themeCfg models.ThemeConfiguration
db.Where("short = ?", theme).
Assign(models.ThemeConfiguration{Short: theme, Data: string(data)}).
FirstOrCreate(&themeCfg)
api.RespondSuccess(c, nil)
}
+49 -97
View File
@@ -14,7 +14,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
)
// 只有一个备份恢复操作在进行
@@ -43,8 +42,14 @@ func UploadBackup(c *gin.Context) {
return
}
// 创建临时文件保存上传的zip
tempFile, err := os.CreateTemp("", "backup-*.zip")
// 确保data目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
return
}
// 创建临时文件保存上传的zip(先校验,再落地到固定位置)
tempFile, err := os.CreateTemp("", "backup-upload-*.zip")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
return
@@ -61,112 +66,59 @@ func UploadBackup(c *gin.Context) {
}
tempFile.Close() // 关闭文件以便后续操作
// 打开zip文件准备解压
zipReader, err := zip.OpenReader(tempFilePath)
if err != nil {
// 基础校验:检查是否包含标记文件
if zr, err := zip.OpenReader(tempFilePath); err == nil {
hasMarkup := false
for _, f := range zr.File {
if f.Name == "komari-backup-markup" {
hasMarkup = true
break
}
}
zr.Close()
if !hasMarkup {
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
return
}
} else {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
return
}
defer zipReader.Close()
// 检查是否包含备份标记文件
hasMarkupFile := false
for _, zipFile := range zipReader.File {
if zipFile.Name == "komari-backup-markup" {
hasMarkupFile = true
break
}
}
if !hasMarkupFile {
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
return
}
// 确保data目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
return
}
// 获取数据库文件名
dbFileName := filepath.Base(flags.DatabaseFile)
// 解压文件
for _, zipFile := range zipReader.File {
// 检查文件路径是否安全(防止路径遍历攻击)
filePath := zipFile.Name
if strings.Contains(filePath, "..") {
log.Printf("Potentially unsafe path in zip: %s, skipping", filePath)
continue
}
// 跳过备份标记文件
if filePath == "komari-backup-markup" {
continue
}
// 确定目标路径
var destPath string
if filePath == dbFileName {
// 如果是数据库文件,恢复到数据库文件路径
destPath = flags.DatabaseFile
} else {
// 其他文件恢复到data目录
destPath = filepath.Join("./data", filePath)
}
// 如果是目录,创建目录
if zipFile.FileInfo().IsDir() {
if err := os.MkdirAll(destPath, 0755); err != nil {
log.Printf("Error creating directory %s: %v", destPath, err)
}
continue
}
// 确保目标文件的目录存在
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
log.Printf("Error creating directory %s: %v", destDir, err)
continue
}
// 打开zip中的文件
srcFile, err := zipFile.Open()
if err != nil {
log.Printf("Error opening file from zip %s: %v", filePath, err)
continue
}
// 创建目标文件
destFile, err := os.Create(destPath)
if err != nil {
srcFile.Close()
log.Printf("Error creating file %s: %v", destPath, err)
continue
}
// 复制内容
_, err = io.Copy(destFile, srcFile)
srcFile.Close()
destFile.Close()
if err != nil {
log.Printf("Error extracting file %s: %v", destPath, err)
continue
}
// 保持原始文件的修改时间
if err := os.Chtimes(destPath, zipFile.Modified, zipFile.Modified); err != nil {
log.Printf("Error setting file time for %s: %v", destPath, err)
// 将校验通过的临时文件移动到固定路径 ./data/backup.zip
finalPath := filepath.Join(".", "data", "backup.zip")
// 如存在旧文件,先删除
_ = os.Remove(finalPath)
if err := os.Rename(tempFilePath, finalPath); err != nil {
// fallback:拷贝
in, err2 := os.Open(tempFilePath)
if err2 != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error preparing backup file: %v", err))
return
}
defer in.Close()
out, err2 := os.Create(finalPath)
if err2 != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating target backup file: %v", err2))
return
}
if _, err2 = io.Copy(out, in); err2 != nil {
out.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
return
}
out.Close()
}
// 返回:已保存备份,重启后将自动恢复
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Backup restored successfully. The service will restart shortly.",
"message": "Backup uploaded successfully. The service will restart and apply the backup.",
"path": "./data/backup.zip",
})
go func() {
log.Println("Backup restored, restarting service in 2 seconds...")
log.Println("Backup uploaded, restarting service in 2 seconds to apply on startup...")
time.Sleep(2 * time.Second)
os.Exit(0)
}()
+41
View File
@@ -0,0 +1,41 @@
package client
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils"
)
func RegisterClient(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
cfg, err := config.Get()
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if cfg.AutoDiscoveryKey == "" ||
len(cfg.AutoDiscoveryKey) < 12 ||
"Bearer "+cfg.AutoDiscoveryKey != auth {
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
name := c.Query("name")
if name == "" {
name = utils.GenerateRandomString(8)
}
name = "Auto-" + name
uuid, token, err := clients.CreateClientWithName(name)
if err != nil {
api.RespondError(c, 500, "Failed to create client: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
}
+76 -55
View File
@@ -22,6 +22,12 @@ import (
"github.com/patrickmn/go-cache"
)
const (
// 如果超过这个时间没有收到任何消息,则认为连接已死
// 因为目前server没有存agent的信息上报间隔。只有写一个默认的
readWait = 11 * time.Second
)
func UploadReport(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
@@ -51,7 +57,7 @@ func UploadReport(c *gin.Context) {
}
// Update report with method and token
ws.GetLatestReport()[report.UUID] = &report
ws.SetLatestReport(report.UUID, &report)
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
@@ -65,7 +71,7 @@ func WebSocketReport(c *gin.Context) {
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
return true // 被控
},
}
// Upgrade the HTTP connection to a WebSocket connection
@@ -109,72 +115,87 @@ func WebSocketReport(c *gin.Context) {
return
}
// 只允许一个客户端的连接
if _, exists := ws.GetConnectedClients()[uuid]; exists {
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
return
// 接受新连接,并处理旧连接
if oldConn, exists := ws.GetConnectedClients()[uuid]; exists {
log.Printf("Client %s is reconnecting. Closing the old connection.", uuid)
// 强制关闭旧连接。这将导致旧连接的 ReadMessage() 循环出错退出。
go oldConn.Close()
}
ws.SetConnectedClients(uuid, conn)
go notifier.OnlineNotification(uuid)
log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
go notifier.OnlineNotification(uuid, conn.ID)
defer func() {
ws.DeleteConnectedClients(uuid)
notifier.OfflineNotification(uuid)
ws.DeleteClientConditionally(uuid, conn)
notifier.OfflineNotification(uuid, conn.ID)
}()
// 首先处理第一次ws conn收到的消息
processMessage(conn, message, uuid)
for {
conn.SetReadDeadline(time.Now().Add(readWait))
_, message, err := conn.ReadMessage()
if err != nil {
break
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("Client %s connection error: %v", uuid, err)
}
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
}
processMessage(conn, message, uuid)
}
}
type MessageType struct {
Type string `json:"type"`
}
var msgType MessageType
err = json.Unmarshal(message, &msgType)
// 将消息处理逻辑提取到一个函数中,方便复用
func processMessage(conn *ws.SafeConn, message []byte, uuid string) {
type MessageType struct {
Type string `json:"type"`
}
var msgType MessageType
err := json.Unmarshal(message, &msgType)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
return
}
switch msgType.Type {
case "", "report":
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
continue
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
return
}
switch msgType.Type {
case "", "report":
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
continue
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
continue
}
ws.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
continue
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
return
}
ws.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
return
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
}
}
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
)
@@ -26,7 +27,7 @@ func TaskResult(c *gin.Context) {
return
}
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, req.FinishedAt); err != nil {
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, models.FromTime(req.FinishedAt)); err != nil {
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
return
}
+1 -1
View File
@@ -22,7 +22,7 @@ func EstablishConnection(c *gin.Context) {
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // TODO: 检查源站
return true // 被控
},
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
+34
View File
@@ -10,6 +10,18 @@ import (
"github.com/gin-gonic/gin"
)
func getClientIPType(ip net.IP) int {
// 0:ipv4 1:ipv6 -1:错误的输入
if ip == nil {
return -1
}
if ip.To4() == nil {
return 1
} else {
return 0
}
}
func UploadBasicInfo(c *gin.Context) {
var cbi = map[string]interface{}{}
if err := c.ShouldBindJSON(&cbi); err != nil {
@@ -26,6 +38,28 @@ func UploadBasicInfo(c *gin.Context) {
cbi["uuid"] = uuid
if (func() bool {
if v4, ok := cbi["ipv4"].(string); !ok || v4 == "" {
if v6, ok := cbi["ipv6"].(string); !ok || v6 == "" {
return true
}
}
return false
})() {
ipStr := c.ClientIP()
ip := net.ParseIP(ipStr)
ipType := getClientIPType(ip)
switch ipType {
case 0:
cbi["ipv4"] = ip
case 1:
cbi["ipv6"] = ip
default:
break
}
}
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
+270
View File
@@ -0,0 +1,270 @@
package jsonRpc
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/rpc"
"github.com/komari-monitor/komari/ws"
)
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
// group: 调用者的权限分组 (guest/client/admin)
// method: RPC 方法名 (如 "common:getNodes")
// params: 方法参数
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
// 检查私有站点
conf, _ := config.Get()
if conf.PrivateSite && group == "guest" {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
}
// 解析方法命名空间
fc := strings.Split(method, ":")
namespace := "common"
if len(fc) >= 2 {
namespace = fc[0]
}
// 权限检查
allowed := false
switch namespace {
case "guest", "", "rpc", "common":
allowed = true
case "client":
if group == "client" || group == "admin" {
allowed = true
}
case "admin":
if group == "admin" {
allowed = true
}
}
if !allowed {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Permission denied", nil)
}
// 构建 context meta
meta := &rpc.ContextMeta{Permission: group}
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), nil, method, params)
}
// Json Rpc2 over websocket, /api/rpc2
func OnRpcRequest(c *gin.Context) {
cfg, _ := config.Get()
// GET -> WebSocket
if c.Request.Method == http.MethodGet {
_conn, err := ws.UpgradeRequest(c, func(r *http.Request) bool {
if cfg.AllowCors {
return true
}
return ws.CheckOrigin(r)
})
conn := ws.NewSafeConn(_conn)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
permissionGroup := detectPermissionGroup(c, cfg)
meta := buildContextMeta(c, permissionGroup)
defer conn.Close()
for {
var req rpc.JsonRpcRequest
err := conn.ReadJSON(&req)
if err != nil {
// JSON 解析
var se *json.SyntaxError
var ute *json.UnmarshalTypeError
if errors.As(err, &se) || errors.As(err, &ute) {
conn.WriteJSON(rpc.ErrorResponse(nil, rpc.InvalidRequest, "bad request: "+err.Error(), nil))
continue
}
// 其它视为连接/IO 错误
break
}
if jerr := req.Validate(); jerr != nil {
conn.WriteJSON(jerr.ResponseWithID(req.ID))
continue
}
dispatchByPermissionWithMeta(conn, permissionGroup, meta, &req)
}
return
}
// 否则按 HTTP POST JSON-RPC 处理 (支持单个或批量)
if c.Request.Method != http.MethodPost {
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "method not allowed"})
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, rpc.ErrorResponse(nil, rpc.ParseError, "read body error", err.Error()))
return
}
requests, jerr := rpc.ParseRequests(body)
if jerr != nil {
c.JSON(http.StatusBadRequest, jerr.Response())
return
}
permissionGroup := detectPermissionGroup(c, cfg)
meta := buildContextMeta(c, permissionGroup)
// 批量
responses := make([]*rpc.JsonRpcResponse, 0, len(requests))
for _, rreq := range requests {
// 权限与命名空间
fc := strings.Split(rreq.Method, ":")
if len(fc) == 1 {
fc[0] = "common"
}
allowed := false
switch fc[0] {
case "guest", "", "rpc", "common":
allowed = true
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
allowed = true
}
case "admin":
if permissionGroup == "admin" {
allowed = true
}
default:
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
continue
}
if !allowed {
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
continue
}
responses = append(responses, rpc.CallWithContext(rpc.NewContextWithMeta(context.TODO(), meta), rreq.ID, rreq.Method, rreq.Params))
}
// 单个请求直接对象,批量请求数组 (符合 JSON-RPC 2.0)
if len(responses) == 1 {
c.JSON(http.StatusOK, responses[0])
} else {
c.JSON(http.StatusOK, responses)
}
}
// detectPermissionGroup 提取权限分组,与原逻辑保持一致
func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
permissionGroup := "guest"
token := c.Query("Authorization")
if _, err := clients.GetClientUUIDByToken(token); err == nil {
permissionGroup = "client"
}
if session_token, _ := c.Cookie("session_token"); session_token != "" {
if _, err := accounts.GetUserBySession(session_token); err == nil {
permissionGroup = "admin"
}
}
apiKey := c.GetHeader("Authorization")
if apiKey == "Bearer "+cfg.ApiKey {
permissionGroup = "admin"
}
return permissionGroup
}
// buildContextMeta 从 gin.Context 构建 *rpc.ContextMeta
func buildContextMeta(c *gin.Context, permissionGroup string) *rpc.ContextMeta {
meta := &rpc.ContextMeta{Permission: permissionGroup}
// 提取客户端 token (query Authorization / Header Authorization Bearer token)
token := c.Query("Authorization")
if token == "" {
// 兼容 header Bearer <token>
hAuth := c.GetHeader("Authorization")
if strings.HasPrefix(hAuth, "Bearer ") {
token = strings.TrimPrefix(hAuth, "Bearer ")
}
}
if token != "" {
if uuid, err := clients.GetClientUUIDByToken(token); err == nil {
meta.ClientToken = token
meta.ClientUUID = uuid
}
}
// 提取用户 (session cookie)
if session_token, _ := c.Cookie("session_token"); session_token != "" {
if user, err := accounts.GetUserBySession(session_token); err == nil {
meta.User = &user
meta.UserUUID = user.UUID
}
}
meta.RemoteIP = c.ClientIP()
meta.UserAgent = c.GetHeader("User-Agent")
return meta
}
// dispatchByPermissionWithMeta 与原函数类似,但会携带 meta 上下文给 handler
func dispatchByPermissionWithMeta(conn *ws.SafeConn, permissionGroup string, meta *rpc.ContextMeta, req *rpc.JsonRpcRequest) {
fc := strings.Split(req.Method, ":")
if len(fc) == 1 {
fc[0] = "common"
}
ctx := rpc.NewContextWithMeta(context.TODO(), meta)
switch fc[0] {
case "guest", "", "rpc", "common":
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
}
case "admin":
if permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
}
default:
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
}
}
// registry holds method handlers keyed by "namespace:MethodName"
var (
registryMu sync.RWMutex
registry = make(map[string]reflect.Value)
)
// Register 将以分组common注册
func Register(name string, cb rpc.Handler) error {
return RegisterWithGroupAndMeta(name, "common", cb, &rpc.MethodMeta{
Name: name,
Summary: "This method does not provide a summary",
Description: "This method does not provide a description",
})
}
// RegisterWithGroup 将回调按分组注册。
// 传入函数: Register("ns", someFunc) -> 注册 ns:SomeFunc
// group 为空时使用默认分组 "common"。
func RegisterWithGroupAndMeta(name, group string, cb rpc.Handler, meta *rpc.MethodMeta) error {
return rpc.RegisterWithMeta(group+":"+name, cb, meta)
}
// GetRegisteredKeys 返回已注册的方法 key 列表 (调试用)。
func GetRegisteredKeys() []string {
registryMu.RLock()
defer registryMu.RUnlock()
keys := make([]string, 0, len(registry))
for k := range registry {
keys = append(keys, k)
}
return keys
}
+558
View File
@@ -0,0 +1,558 @@
package jsonRpc
import (
"context"
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/rpc"
"github.com/komari-monitor/komari/ws"
cache "github.com/patrickmn/go-cache"
)
// pingstats:<uuid>
var pingStatsCache = cache.New(1*time.Minute, 2*time.Minute)
type pingStat struct {
Name string `json:"name"`
Latest int `json:"latest"`
Avg int `json:"avg"`
Tail float64 `json:"tail"` // (P99-P50)/P50
Loss float64 `json:"loss"` // 丢包率 %
Min int `json:"min"`
Max int `json:"max"`
}
// getPingStatsForNode 计算并缓存节点最近 1 小时 ping 统计
func getPingStatsForNode(uuid string, pingTasks []models.PingTask) map[string]pingStat {
if uuid == "" {
return map[string]pingStat{}
}
key := fmt.Sprintf("pingstats:%s", uuid)
if v, ok := pingStatsCache.Get(key); ok {
if m, ok2 := v.(map[string]pingStat); ok2 {
return m
}
}
// 筛选属于该节点的任务
assigned := make([]models.PingTask, 0, 4)
for _, t := range pingTasks {
for _, c := range t.Clients {
if c == uuid {
assigned = append(assigned, t)
break
}
}
}
if len(assigned) == 0 {
empty := map[string]pingStat{}
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
return empty
}
end := time.Now()
start := end.Add(-1 * time.Hour)
recs, err := tasks.GetPingRecords(uuid, -1, start, end)
if err != nil || len(recs) == 0 {
empty := map[string]pingStat{}
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
return empty
}
grouped := make(map[uint][]models.PingRecord)
for _, r := range recs {
for _, t := range assigned {
if r.TaskId == t.Id {
grouped[r.TaskId] = append(grouped[r.TaskId], r)
break
}
}
}
result := make(map[string]pingStat, len(grouped))
for _, t := range assigned {
records := grouped[t.Id]
if len(records) == 0 {
continue
}
latest := -1
var latestTs time.Time
values := make([]int, 0, len(records))
sum := 0
valid := 0
total := 0
lossCount := 0
minLat := 0
maxLat := 0
for _, r := range records {
total++
if r.Value < 0 { // 丢包
lossCount++
continue
}
values = append(values, r.Value)
sum += r.Value
valid++
if minLat == 0 || r.Value < minLat {
minLat = r.Value
}
if r.Value > maxLat {
maxLat = r.Value
}
ts := r.Time.ToTime()
if latestTs.IsZero() || ts.After(latestTs) {
latestTs = ts
latest = r.Value
}
}
avg := 0
if valid > 0 {
avg = sum / valid
}
p50, p99 := 0, 0
if len(values) > 0 {
sort.Ints(values)
percentile := func(vals []int, pct float64) int {
if len(vals) == 0 {
return 0
}
if pct <= 0 {
return vals[0]
}
if pct >= 1 {
return vals[len(vals)-1]
}
pos := (float64(len(vals) - 1)) * pct
lo := int(math.Floor(pos))
hi := int(math.Ceil(pos))
if lo == hi {
return vals[lo]
}
frac := pos - float64(lo)
v := float64(vals[lo]) + (float64(vals[hi])-float64(vals[lo]))*frac
return int(math.Round(v))
}
p50 = percentile(values, 0.50)
p99 = percentile(values, 0.99)
}
tail := 0.0
if p50 > 0 && p99 >= p50 {
tail = float64(p99-p50) / float64(p50)
}
lossRate := 0.0
if total > 0 {
lossRate = float64(lossCount) / float64(total) * 100
}
result[fmt.Sprintf("%d", t.Id)] = pingStat{
Name: t.Name,
Latest: latest,
Avg: avg,
Tail: tail,
Loss: lossRate,
Min: minLat,
Max: maxLat,
}
}
pingStatsCache.Set(key, result, cache.DefaultExpiration)
return result
}
func init() {
RegisterWithGroupAndMeta("getNodes", "common",
func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return getNodes(ctx, req)
},
&rpc.MethodMeta{
Name: "getNodes",
Summary: "Get all nodes",
Params: []rpc.ParamMeta{
{
Name: "uuid",
Description: "Specify the UUID of the node",
Required: false,
Type: "string",
},
},
Returns: "Client | { [uuid]: Client }",
},
)
RegisterWithGroupAndMeta("getNodesLatestStatus", "common",
func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return getNodesLatestStatus(ctx, req)
},
&rpc.MethodMeta{
Name: "getNodesLatestStatus",
Summary: "Get latest status reports (single node or map).",
Params: []rpc.ParamMeta{
{
Name: "uuid",
Description: "Specify the UUID of the node (optional)",
Required: false,
Type: "string",
},
{
Name: "uuids",
Description: "Specify multiple UUIDs (array) to get subset (ignored if uuid provided)",
Required: false,
Type: "string[]",
},
},
Returns: "Record | { [uuid]: Record }",
},
)
Register("getMe", func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return getMe(ctx, req)
})
Register("getPublicInfo", getPublicInfo)
Register("getVersion", getVersion)
Register("getNodeRecentStatus", getNodeRecentStatus)
}
func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
req.BindParams(&params)
cinfo, err := clients.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", cinfo)
}
meta := rpc.MetaFromContext(ctx)
cfg, _ := config.Get()
if meta.Permission != "admin" {
// 过滤 Hidden 节点并隐藏敏感字段
filtered := make([]models.Client, 0, len(cinfo))
for _, node := range cinfo {
if node.Hidden { // 非 admin 不显示隐藏节点
continue
}
if cfg.SendIpAddrToGuest {
if node.IPv4 != "" {
node.IPv4 = strings.Split(node.IPv4, ".")[0] + ".*.*.*"
}
if node.IPv6 != "" {
node.IPv6 = strings.Split(node.IPv6, ":")[0] + ":*:*:*:*:*:*:*"
}
} else {
node.IPv4 = ""
node.IPv6 = ""
}
node.Remark = ""
node.Version = ""
node.Token = ""
filtered = append(filtered, node)
}
cinfo = filtered
}
if params.UUID != "" {
for _, node := range cinfo {
if node.UUID == params.UUID {
return node, nil
}
}
return nil, rpc.MakeError(rpc.InvalidParams, "Node not found", params.UUID)
}
nodesMap := make(map[string]models.Client, len(cinfo))
for _, node := range cinfo {
nodesMap[node.UUID] = node
}
return nodesMap, nil
}
func getPublicInfo(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
info, err := database.GetPublicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get public info", err.Error())
}
return info, nil
}
func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
UUIDs []string `json:"uuids"`
}
req.BindParams(&params)
meta := rpc.MetaFromContext(ctx)
latest := ws.GetLatestReport() // map[string]*common.Report (copy)
connected := ws.GetConnectedClients()
onlineSet := make(map[string]bool, len(connected))
for uuid := range connected {
onlineSet[uuid] = true
}
// Hidden 过滤
if meta.Permission != "admin" {
cinfo, err := clients.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
}
hidden := make(map[string]bool, len(cinfo))
for _, c := range cinfo {
if c.Hidden {
hidden[c.UUID] = true
}
}
for uuid := range latest {
if hidden[uuid] {
delete(latest, uuid)
}
}
}
// 如果指定 uuid 但找不到,直接返回 not found
if params.UUID != "" {
if _, ok := latest[params.UUID]; !ok {
return nil, rpc.MakeError(rpc.InvalidParams, "Node not found", params.UUID)
}
}
type recordLike struct {
Client string `json:"client"`
Time models.LocalTime `json:"time"`
Cpu float32 `json:"cpu"`
Gpu float32 `json:"gpu"`
Ram int64 `json:"ram"`
RamTotal int64 `json:"ram_total"`
Swap int64 `json:"swap"`
SwapTotal int64 `json:"swap_total"`
Load float32 `json:"load"`
Load5 float32 `json:"load5"`
Load15 float32 `json:"load15"`
Temp float32 `json:"temp"`
Disk int64 `json:"disk"`
DiskTotal int64 `json:"disk_total"`
NetIn int64 `json:"net_in"`
NetOut int64 `json:"net_out"`
NetTotalUp int64 `json:"net_total_up"`
NetTotalDown int64 `json:"net_total_down"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
Online bool `json:"online"`
Uptime int64 `json:"uptime"`
Ping map[string]pingStat `json:"ping"`
}
respMap := make(map[string]recordLike, len(latest))
// 预取所有 ping 任务
pingTasks, _ := tasks.GetAllPingTasks()
appendOne := func(uuid string, rep *common.Report) {
if rep == nil {
return
}
stats := getPingStatsForNode(uuid, pingTasks)
rl := recordLike{
Client: uuid,
Time: models.FromTime(rep.UpdatedAt),
Cpu: float32(rep.CPU.Usage),
Gpu: 0,
Ram: rep.Ram.Used,
RamTotal: rep.Ram.Total,
Swap: rep.Swap.Used,
SwapTotal: rep.Swap.Total,
Load: float32(rep.Load.Load1),
Load5: float32(rep.Load.Load5),
Load15: float32(rep.Load.Load15),
Temp: 0,
Disk: rep.Disk.Used,
DiskTotal: rep.Disk.Total,
NetIn: rep.Network.Down,
NetOut: rep.Network.Up,
NetTotalUp: rep.Network.TotalUp,
NetTotalDown: rep.Network.TotalDown,
Process: rep.Process,
Connections: rep.Connections.TCP + rep.Connections.UDP,
ConnectionsUdp: rep.Connections.UDP,
Online: onlineSet[uuid],
Uptime: rep.Uptime,
Ping: stats,
}
respMap[uuid] = rl
}
// 选择逻辑
if params.UUID != "" { // 单个
appendOne(params.UUID, latest[params.UUID])
return respMap[params.UUID], nil
}
selected := map[string]bool{}
if len(params.UUIDs) > 0 {
for _, id := range params.UUIDs {
selected[id] = true
}
for uuid, rep := range latest {
if selected[uuid] {
appendOne(uuid, rep)
}
}
return respMap, nil
}
for uuid, rep := range latest {
appendOne(uuid, rep)
}
return respMap, nil
}
func getMe(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var resp struct {
TwoFAEnabled bool `json:"2fa_enabled"`
LoggedIn bool `json:"logged_in"`
SSOId string `json:"sso_id"`
SSOType string `json:"sso_type"`
Username string `json:"username"`
UUID string `json:"uuid"`
}
meta := rpc.MetaFromContext(ctx)
switch meta.Permission {
case "admin":
resp.TwoFAEnabled = meta.User.TwoFactor != ""
resp.LoggedIn = true
resp.SSOId = meta.User.SSOID
resp.SSOType = meta.User.SSOType
resp.Username = meta.User.Username
resp.UUID = meta.User.UUID
return resp, nil
case "guest":
resp.LoggedIn = false
return resp, nil
case "client":
resp.LoggedIn = true
resp.SSOId = "client"
resp.SSOType = "client"
resp.Username = "client"
resp.UUID = meta.ClientToken
client, err := clients.GetClientUUIDByToken(meta.ClientToken)
if err != nil {
resp.UUID = client
}
return resp, nil
default:
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid user role", meta.Permission)
}
}
func getVersion(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return struct {
Version string `json:"version"`
Hash string `json:"hash"`
}{
Version: utils.CurrentVersion,
Hash: utils.VersionHash,
}, nil
}
func getNodeRecentStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
req.BindParams(&params)
if params.UUID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", params)
}
meta := rpc.MetaFromContext(ctx)
// 登录状态检查
isLogin := false
if meta.Permission == "admin" {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
hiddenMap := map[string]bool{}
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if hiddenMap[params.UUID] {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", params) //防止未登录用户获取隐藏客户端数据
}
}
raw, _ := api.Records.Get(params.UUID)
reports, _ := raw.([]common.Report)
// 扁平化为 { count, records: [] }
type flatRecord struct {
Client string `json:"client"`
Time models.LocalTime `json:"time"`
Cpu float32 `json:"cpu"`
Gpu float32 `json:"gpu"`
Ram int64 `json:"ram"`
RamTotal int64 `json:"ram_total"`
Swap int64 `json:"swap"`
SwapTotal int64 `json:"swap_total"`
Load float32 `json:"load"`
Temp float32 `json:"temp"`
Disk int64 `json:"disk"`
DiskTotal int64 `json:"disk_total"`
NetIn int64 `json:"net_in"`
NetOut int64 `json:"net_out"`
NetTotalUp int64 `json:"net_total_up"`
NetTotalDown int64 `json:"net_total_down"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
}
resp := struct {
Count int `json:"count"`
Records []flatRecord `json:"records"`
}{
Count: 0,
Records: []flatRecord{},
}
if len(reports) == 0 {
return resp, nil
}
resp.Records = make([]flatRecord, 0, len(reports))
for _, r := range reports {
fr := flatRecord{
Client: params.UUID,
Time: models.FromTime(r.UpdatedAt),
Cpu: float32(r.CPU.Usage),
Gpu: 0,
Ram: r.Ram.Used,
RamTotal: r.Ram.Total,
Swap: r.Swap.Used,
SwapTotal: r.Swap.Total,
Load: float32(r.Load.Load1),
Temp: 0,
Disk: r.Disk.Used,
DiskTotal: r.Disk.Total,
NetIn: r.Network.Down,
NetOut: r.Network.Up,
NetTotalUp: r.Network.TotalUp,
NetTotalDown: r.Network.TotalDown,
Process: r.Process,
Connections: r.Connections.TCP + r.Connections.UDP,
ConnectionsUdp: r.Connections.UDP,
}
resp.Records = append(resp.Records, fr)
}
resp.Count = len(resp.Records)
return resp, nil
}
+806
View File
@@ -0,0 +1,806 @@
package jsonRpc
import (
"context"
"fmt"
"math"
"sort"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
recordsdb "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/rpc"
)
func init() {
Register("getRecords", getRecords)
}
func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
meta := rpc.MetaFromContext(ctx)
var params struct {
Type string `json:"type"` // "load" | "ping"; default "load"
UUID string `json:"uuid"` // client uuid; empty = all clients
Hours int `json:"hours"` // time window in hours; default 1 if start/end not provided
Start string `json:"start"` // RFC3339 start time (optional)
End string `json:"end"` // RFC3339 end time (optional)
LoadType string `json:"load_type"` // for type=load: cpu|gpu|ram|swap|load|temp|disk|network|process|connections|all
TaskID int `json:"task_id"` // for type=ping: optional task id; -1 or omitted means all
MaxCount int `json:"maxCount"` // max number of points; -1 unlimited; default 4000
}
req.BindParams(&params)
// defaults
if params.Type == "" {
params.Type = "load"
}
// parse time window
var startTime, endTime time.Time
if params.Start != "" || params.End != "" {
// allow partial: missing end means now
var err error
if params.End == "" {
endTime = time.Now()
} else {
endTime, err = time.Parse(time.RFC3339, params.End)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid end time", params.End)
}
}
if params.Start == "" {
// default to 1 hour before end
startTime = endTime.Add(-1 * time.Hour)
} else {
start, err := time.Parse(time.RFC3339, params.Start)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid start time", params.Start)
}
startTime = start
}
} else {
hours := params.Hours
if hours <= 0 {
hours = 1 // default 1 hour
}
endTime = time.Now()
startTime = endTime.Add(-time.Duration(hours) * time.Hour)
}
// Hidden filtering for non-admin
isAdmin := meta.Permission == "admin"
hidden := map[string]bool{}
if !isAdmin {
cinfo, err := clients.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
}
for _, c := range cinfo {
if c.Hidden {
hidden[c.UUID] = true
}
}
if params.UUID != "" && hidden[params.UUID] {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID not found", params.UUID)
}
}
switch params.Type {
case "load":
// fetch load records
recs, err := getLoadRecordsCombined(params.UUID, startTime, endTime)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch records", err.Error())
}
// hidden filter on non-admin
if !isAdmin {
filtered := recs[:0]
for _, r := range recs {
if hidden[r.Client] {
continue
}
filtered = append(filtered, r)
}
recs = filtered
}
// resolve maxCount default for load
maxCount := params.MaxCount
if maxCount == 0 {
maxCount = 4000
}
// optional load_type filtering -> group by client
if params.LoadType != "" && params.LoadType != "all" {
items := filterRecordsByLoadType(recs, params.LoadType)
grouped := make(map[string][]flatRecord)
for _, it := range items {
grouped[it.Client] = append(grouped[it.Client], it)
}
// sort and count
total := 0
groupsMeta := make([]struct {
name string
length int
}, 0, len(grouped))
for name := range grouped {
arr := grouped[name]
sort.Slice(arr, func(i, j int) bool { return arr[i].Time.ToTime().Before(arr[j].Time.ToTime()) })
grouped[name] = arr
l := len(arr)
total += l
groupsMeta = append(groupsMeta, struct {
name string
length int
}{name: name, length: l})
}
// downsample across all clients proportionally
if maxCount != -1 && total > maxCount {
targets := allocateTargets(groupsMeta, maxCount)
total = 0
for name, k := range targets {
grouped[name] = downsampleFlatRecords(grouped[name], k)
total += len(grouped[name])
}
}
return struct {
Count int `json:"count"`
Records map[string][]flatRecord `json:"records"`
LoadType string `json:"load_type"`
From models.LocalTime `json:"from"`
To models.LocalTime `json:"to"`
}{Count: total, Records: grouped, LoadType: params.LoadType, From: models.FromTime(startTime), To: models.FromTime(endTime)}, nil
}
// default: return full records, grouped by client
grouped := make(map[string][]models.Record)
for _, r := range recs {
grouped[r.Client] = append(grouped[r.Client], r)
}
total := 0
groupsMeta := make([]struct {
name string
length int
}, 0, len(grouped))
for name := range grouped {
arr := grouped[name]
sort.Slice(arr, func(i, j int) bool { return arr[i].Time.ToTime().Before(arr[j].Time.ToTime()) })
grouped[name] = arr
l := len(arr)
total += l
groupsMeta = append(groupsMeta, struct {
name string
length int
}{name: name, length: l})
}
if maxCount != -1 && total > maxCount {
targets := allocateTargets(groupsMeta, maxCount)
total = 0
for name, k := range targets {
grouped[name] = downsampleModelRecords(grouped[name], k)
total += len(grouped[name])
}
}
return struct {
Count int `json:"count"`
Records map[string][]models.Record `json:"records"`
From models.LocalTime `json:"from"`
To models.LocalTime `json:"to"`
}{Count: total, Records: grouped, From: models.FromTime(startTime), To: models.FromTime(endTime)}, nil
case "ping":
taskId := params.TaskID
if taskId == 0 {
taskId = -1
}
recs, err := tasks.GetPingRecords(params.UUID, taskId, startTime, endTime)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping records", err.Error())
}
// hidden filter
if !isAdmin {
filtered := recs[:0]
for _, r := range recs {
if r.Client != "" && hidden[r.Client] {
continue
}
filtered = append(filtered, r)
}
recs = filtered
}
type RecordsResp struct {
TaskId uint `json:"task_id,omitempty"`
Time models.LocalTime `json:"time"`
Value int `json:"value"`
Client string `json:"client,omitempty"`
}
type ClientBasicInfo struct {
Client string `json:"client"`
Loss float64 `json:"loss"`
Min int `json:"min"`
Max int `json:"max"`
}
type Resp struct {
Count int `json:"count"`
BasicInfo []ClientBasicInfo `json:"basic_info,omitempty"`
Records []RecordsResp `json:"records"`
Tasks []map[string]any `json:"tasks"`
From models.LocalTime `json:"from"`
To models.LocalTime `json:"to"`
}
response := &Resp{Count: 0, Records: []RecordsResp{}, From: models.FromTime(startTime), To: models.FromTime(endTime)}
// stats per client
clientStats := make(map[string]struct {
total int
loss int
min int
max int
})
for _, r := range recs {
rr := RecordsResp{
TaskId: r.TaskId,
Time: r.Time,
Value: r.Value,
Client: r.Client,
}
st := clientStats[r.Client]
st.total++
if r.Value < 0 {
st.loss++
} else {
if st.min == 0 || r.Value < st.min {
st.min = r.Value
}
if r.Value > st.max {
st.max = r.Value
}
}
clientStats[r.Client] = st
response.Records = append(response.Records, rr)
}
if len(clientStats) > 0 {
response.BasicInfo = make([]ClientBasicInfo, 0, len(clientStats))
for client, st := range clientStats {
if client != "" && !isAdmin && hidden[client] {
continue
}
loss := float64(0)
if st.total > 0 {
loss = float64(st.loss) / float64(st.total) * 100
}
response.BasicInfo = append(response.BasicInfo, ClientBasicInfo{
Client: client,
Loss: loss,
Min: st.min,
Max: st.max,
})
}
}
// tasks summary (always included for ping type; do not expose target field)
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping tasks", err.Error())
}
toList := make([]map[string]any, 0, len(pingTasks))
for _, t := range pingTasks {
if taskId != -1 && t.Id != uint(taskId) {
continue
}
if params.UUID != "" { // ensure task assigned to specific client when filtering by uuid
assigned := false
for _, c := range t.Clients {
if c == params.UUID {
assigned = true
break
}
}
if !assigned {
continue
}
}
total := 0
lossCount := 0
minLat := 0
maxLat := 0
sum := 0
valid := 0
latestVal := -1
var latestTs time.Time
// 收集该任务的所有有效(非丢包)延迟值以计算百分位
latencies := make([]int, 0, 64)
for _, r := range recs {
if r.TaskId != t.Id {
continue
}
if params.UUID != "" && r.Client != params.UUID {
continue
}
total++
if r.Value < 0 { // 丢包
lossCount++
continue
}
valid++
sum += r.Value
latencies = append(latencies, r.Value)
if minLat == 0 || r.Value < minLat {
minLat = r.Value
}
if r.Value > maxLat {
maxLat = r.Value
}
// track latest non-negative value
ts := r.Time.ToTime()
if latestTs.IsZero() || ts.After(latestTs) {
latestTs = ts
latestVal = r.Value
}
}
// 计算 P50 / P99
p50 := 0
p99 := 0
if len(latencies) > 0 {
sort.Ints(latencies)
getPercentileInt := func(values []int, percentile float64) int {
if len(values) == 0 {
return 0
}
if percentile <= 0 {
return values[0]
}
if percentile >= 1 {
return values[len(values)-1]
}
pos := (float64(len(values) - 1)) * percentile
lo := int(math.Floor(pos))
hi := int(math.Ceil(pos))
if lo == hi {
return values[lo]
}
frac := pos - float64(lo)
v := float64(values[lo]) + (float64(values[hi])-float64(values[lo]))*frac
return int(math.Round(v))
}
p50 = getPercentileInt(latencies, 0.50)
p99 = getPercentileInt(latencies, 0.99)
}
ratio := 0.0
if p50 > 0 && p99 >= p50 {
jitterMs := float64(p99 - p50)
adjustedBase := math.Max(math.Min(float64(p50), 50.0), 10.0)
ratio = jitterMs / adjustedBase
}
lossRate := 0.0
if total > 0 {
lossRate = float64(lossCount) / float64(total) * 100
}
avg := 0
if valid > 0 {
avg = sum / valid
}
info := map[string]any{
"id": t.Id,
"name": t.Name,
"type": t.Type,
"interval": t.Interval,
"loss": lossRate,
"min": minLat,
"max": maxLat,
"avg": avg,
"latest": latestVal,
"total": total,
"p50": p50,
"p99": p99,
"p99_p50_ratio": ratio,
}
if params.UUID == "" && taskId != -1 { // retain existing behavior of exposing clients only when filtering by task
info["clients"] = t.Clients
}
toList = append(toList, info)
}
response.Tasks = toList
// apply maxCount for ping
maxCount := params.MaxCount
if maxCount == 0 {
maxCount = 4000
}
if maxCount != -1 && len(response.Records) > maxCount {
// group records by TaskId for proportional downsampling
taskGroups := make(map[uint][]RecordsResp)
for _, r := range response.Records {
taskGroups[r.TaskId] = append(taskGroups[r.TaskId], r)
}
// sort each group by time
for taskId := range taskGroups {
sort.Slice(taskGroups[taskId], func(i, j int) bool {
return taskGroups[taskId][i].Time.ToTime().Before(taskGroups[taskId][j].Time.ToTime())
})
}
// calculate proportional allocation for each task
type taskMeta struct {
taskId uint
length int
}
groupsMeta := make([]taskMeta, 0, len(taskGroups))
for taskId, records := range taskGroups {
groupsMeta = append(groupsMeta, taskMeta{
taskId: taskId,
length: len(records),
})
}
// use existing allocateTargets function (create compatible struct)
strTargets := allocateTargets(
func() []struct {
name string
length int
} {
result := make([]struct {
name string
length int
}, len(groupsMeta))
for i, meta := range groupsMeta {
result[i] = struct {
name string
length int
}{name: fmt.Sprintf("%d", meta.taskId), length: meta.length}
}
return result
}(),
maxCount,
)
// downsample each task group
downsampledRecords := make([]RecordsResp, 0, maxCount)
samplePingRecords := func(in []RecordsResp, k int) []RecordsResp {
n := len(in)
if k <= 0 || n == 0 {
return []RecordsResp{}
}
if k >= n {
return in
}
out := make([]RecordsResp, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
for taskId, records := range taskGroups {
targetKey := fmt.Sprintf("%d", taskId)
targetCount := strTargets[targetKey]
sampled := samplePingRecords(records, targetCount)
downsampledRecords = append(downsampledRecords, sampled...)
}
response.Records = downsampledRecords
}
response.Count = len(response.Records)
// sort by time asc
sort.Slice(response.Records, func(i, j int) bool {
return response.Records[i].Time.ToTime().Before(response.Records[j].Time.ToTime())
})
return response, nil
default:
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid type, expected 'load' or 'ping'", params.Type)
}
}
// ---------- helpers for load records ----------
// getLoadRecordsCombined fetches records for a client or all clients within a time range,
// combining recent short-term table and long-term table with 15-min grouping for recent part.
func getLoadRecordsCombined(uuid string, start, end time.Time) ([]models.Record, error) {
// prefer the existing function when uuid provided
if uuid != "" {
return recordsdb.GetRecordsByClientAndTime(uuid, start, end)
}
db := dbcore.GetDBInstance()
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recent []models.Record
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
_ = db.Table("records").Where("time >= ? AND time <= ?", recentStart, end).Order("time ASC").Find(&recent).Error
}
var longTerm []models.Record
_ = db.Table("records_long_term").Where("time >= ? AND time <= ?", start, end).Order("time ASC").Find(&longTerm).Error
// if no long term, return all recent
if len(longTerm) == 0 {
return recent, nil
}
// group recent by client+15min, keep latest in bucket
type key struct {
c string
slot string
}
grouped := make(map[key]models.Record)
for _, rec := range recent {
k := key{c: rec.Client, slot: rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)}
if old, ok := grouped[k]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
grouped[k] = rec
}
}
flat := make([]models.Record, 0, len(grouped))
for _, rec := range grouped {
flat = append(flat, rec)
}
sort.Slice(flat, func(i, j int) bool { return flat[i].Time.ToTime().Before(flat[j].Time.ToTime()) })
flat = append(flat, longTerm...)
return flat, nil
}
// ---------- downsampling helpers ----------
// allocateTargets splits maxTotal across groups proportionally to their lengths.
func allocateTargets(groups []struct {
name string
length int
}, maxTotal int) map[string]int {
result := make(map[string]int, len(groups))
if maxTotal < 0 {
for _, g := range groups {
result[g.name] = g.length
}
return result
}
total := 0
for _, g := range groups {
total += g.length
}
if total <= maxTotal {
for _, g := range groups {
result[g.name] = g.length
}
return result
}
// initial allocation based on proportion
type rem struct {
idx int
frac float64
}
remainders := make([]rem, 0, len(groups))
sumTargets := 0
for i, g := range groups {
if g.length <= 0 {
result[g.name] = 0
continue
}
raw := float64(g.length) * float64(maxTotal) / float64(total)
t := int(math.Floor(raw))
if t > g.length {
t = g.length
}
result[groups[i].name] = t
sumTargets += t
remainders = append(remainders, rem{i, raw - float64(t)})
}
// distribute remaining by largest fractional parts
if sumTargets < maxTotal {
need := maxTotal - sumTargets
sort.Slice(remainders, func(i, j int) bool { return remainders[i].frac > remainders[j].frac })
for _, r := range remainders {
if need == 0 {
break
}
g := groups[r.idx]
cur := result[g.name]
if cur < g.length {
result[g.name] = cur + 1
need--
}
}
// if still left, second pass round-robin
if need > 0 {
for need > 0 {
for _, g := range groups {
if need == 0 {
break
}
if result[g.name] < g.length {
result[g.name]++
need--
}
}
if need > 0 {
break
}
}
}
} else if sumTargets > maxTotal {
over := sumTargets - maxTotal
sort.Slice(remainders, func(i, j int) bool { return remainders[i].frac < remainders[j].frac })
for _, r := range remainders {
if over == 0 {
break
}
g := groups[r.idx]
if result[g.name] > 0 {
result[g.name]--
over--
}
}
if over > 0 {
for over > 0 {
for _, g := range groups {
if over == 0 {
break
}
if result[g.name] > 0 {
result[g.name]--
over--
}
}
if over > 0 {
break
}
}
}
}
return result
}
func downsampleModelRecords(in []models.Record, k int) []models.Record {
n := len(in)
if k <= 0 || n == 0 {
return []models.Record{}
}
if k >= n {
return in
}
out := make([]models.Record, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
func downsampleFlatRecords(in []flatRecord, k int) []flatRecord {
n := len(in)
if k <= 0 || n == 0 {
return []flatRecord{}
}
if k >= n {
return in
}
out := make([]flatRecord, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
// flatRecord is a projection used when load_type is specified.
type flatRecord struct {
Client string `json:"client"`
Time models.LocalTime `json:"time"`
Cpu *float32 `json:"cpu,omitempty"`
Gpu *float32 `json:"gpu,omitempty"`
Ram *int64 `json:"ram,omitempty"`
RamTotal *int64 `json:"ram_total,omitempty"`
Swap *int64 `json:"swap,omitempty"`
SwapTotal *int64 `json:"swap_total,omitempty"`
Load *float32 `json:"load,omitempty"`
Temp *float32 `json:"temp,omitempty"`
Disk *int64 `json:"disk,omitempty"`
DiskTotal *int64 `json:"disk_total,omitempty"`
NetIn *int64 `json:"net_in,omitempty"`
NetOut *int64 `json:"net_out,omitempty"`
NetTotalUp *int64 `json:"net_total_up,omitempty"`
NetTotalDown *int64 `json:"net_total_down,omitempty"`
Process *int `json:"process,omitempty"`
Connections *int `json:"connections,omitempty"`
ConnectionsUdp *int `json:"connections_udp,omitempty"`
Uptime *int64 `json:"uptime,omitempty"`
}
func filterRecordsByLoadType(recs []models.Record, loadType string) []flatRecord {
out := make([]flatRecord, 0, len(recs))
for _, r := range recs {
fr := flatRecord{Client: r.Client, Time: r.Time}
// always include uptime when present
//if r.Uptime != 0 {
// v := r.Uptime
// fr.Uptime = &v
//}
switch loadType {
case "cpu":
v := r.Cpu
fr.Cpu = &v
case "gpu":
v := r.Gpu
fr.Gpu = &v
case "ram":
v := r.Ram
fr.Ram = &v
vt := r.RamTotal
fr.RamTotal = &vt
case "swap":
v := r.Swap
fr.Swap = &v
vt := r.SwapTotal
fr.SwapTotal = &vt
case "load":
v := r.Load
fr.Load = &v
case "temp":
v := r.Temp
fr.Temp = &v
case "disk":
v := r.Disk
fr.Disk = &v
vt := r.DiskTotal
fr.DiskTotal = &vt
case "network":
vi := r.NetIn
vo := r.NetOut
vtu := r.NetTotalUp
vtd := r.NetTotalDown
fr.NetIn = &vi
fr.NetOut = &vo
fr.NetTotalUp = &vtu
fr.NetTotalDown = &vtd
case "process":
v := r.Process
fr.Process = &v
case "connections":
v := r.Connections
fr.Connections = &v
vu := r.ConnectionsUdp
fr.ConnectionsUdp = &vu
default:
// unknown type: fallback to all fields as a full record would be returned elsewhere
v := r.Cpu
fr.Cpu = &v
}
out = append(out, fr)
}
return out
}
+29
View File
@@ -2,14 +2,43 @@ package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetClientRecentRecords(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
RespondError(c, 400, "UUID is required")
return
}
// 登录状态检查
isLogin := false
session, _ := c.Cookie("session_token")
_, err := accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
hiddenMap := map[string]bool{}
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if hiddenMap[uuid] {
RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
records, _ := Records.Get(uuid)
RespondSuccess(c, records)
}
+9 -7
View File
@@ -48,13 +48,15 @@ func Login(c *gin.Context) {
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" && data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
if user.TwoFactor != "" { // 开启了2FA
if data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
+17 -4
View File
@@ -2,6 +2,7 @@ package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
)
@@ -11,16 +12,28 @@ func GetNodesInformation(c *gin.Context) {
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
return
}
isLogin := false
session, _ := c.Cookie("session_token")
_, err = accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
count := len(clientList)
// 公开信息不展示IP地址,私有备注不展示
for i := 0; i < count; i++ {
// 过滤掉 Hidden 的客户端,并清理需要隐藏的字段
j := 0
for i := 0; i < len(clientList); i++ {
if clientList[i].Hidden && !isLogin { // 不返回 Hidden 客户端
continue
}
clientList[i].IPv4 = ""
clientList[i].IPv6 = ""
clientList[i].Remark = ""
clientList[i].Remark = "" // 私有备注不展示
clientList[i].Version = ""
clientList[i].Token = ""
clientList[j] = clientList[i]
j++
}
clientList = clientList[:j]
RespondSuccess(c, clientList)
}
+24 -6
View File
@@ -2,11 +2,13 @@ package api
import (
"fmt"
"slices"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth"
)
@@ -18,7 +20,7 @@ func OAuth(c *gin.Context) {
return
}
authURL, state := oauth.CurrentProvider().GetAuthorizationURL()
authURL, state := oauth.CurrentProvider().GetAuthorizationURL(utils.GetCallbackURL(c))
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
@@ -27,12 +29,28 @@ func OAuth(c *gin.Context) {
// /api/oauth_callback
func OAuthCallback(c *gin.Context) {
// 验证state防止CSRF攻击
state, _ := c.Cookie("oauth_state")
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
// 获取当前OAuth提供商名称
providerName := oauth.CurrentProvider().GetName()
providersSkipStateCheck := []string{"qq"}
if slices.Contains(providersSkipStateCheck, providerName) {
// 对于QQ登录,由于是通过QQ聚合登录平台中转,state可能会不匹配
// 但我们仍然需要验证state的存在性(不能是空的)
if state == "" {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
} else {
// 对于其他提供商,严格验证state匹配
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
}
queries := make(map[string]string)
@@ -41,7 +59,7 @@ func OAuthCallback(c *gin.Context) {
queries[key] = values[0]
}
}
oidcUser, err := oauth.CurrentProvider().OnCallback(c.Request.Context(), state, queries)
oidcUser, err := oauth.CurrentProvider().OnCallback(c, state, queries, utils.GetCallbackURL(c))
if err != nil {
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
return
@@ -68,7 +86,7 @@ func OAuthCallback(c *gin.Context) {
return
}
auditlog.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
c.Redirect(302, "/manage")
c.Redirect(302, "/admin")
return
}
+5 -20
View File
@@ -2,29 +2,14 @@ package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database"
)
func GetPublicSettings(c *gin.Context) {
cst, err := config.Get()
if err != nil {
RespondError(c, 500, err.Error())
p, e := database.GetPublicInfo()
if e != nil {
RespondError(c, 500, e.Error())
return
}
// Return public settings including CORS
RespondSuccess(c, gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
"private_site": cst.PrivateSite,
})
RespondSuccess(c, p)
}
+1624
View File
File diff suppressed because it is too large. Load diff
+374 -54
View File
@@ -1,17 +1,47 @@
package record
import (
"slices"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
records "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
)
func GetRecordsByUUID(c *gin.Context) {
uuid := c.Query("uuid")
loadType := c.Query("load_type")
// 登录状态检查
isLogin := false
session, _ := c.Cookie("session_token")
_, err := accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
hiddenMap := map[string]bool{}
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if hiddenMap[uuid] {
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
@@ -27,101 +57,391 @@ func GetRecordsByUUID(c *gin.Context) {
return
}
records, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
// 验证 load_type 参数
validLoadTypes := map[string]bool{
"cpu": true, "ram": true, "swap": true,
"load": true, "temp": true, "disk": true, "network": true,
"process": true, "connections": true, "all": true, "": true,
}
if !validLoadTypes[loadType] {
api.RespondError(c, 400, "Invalid load_type parameter")
return
}
clientRecords, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{
"records": records,
"count": len(records),
})
// 准备基本响应
response := gin.H{
"records": clientRecords,
"count": len(clientRecords),
}
// 如果有load_type过滤,应用过滤
if loadType != "" && loadType != "all" {
filteredRecords := filterRecordsByLoadType(clientRecords, loadType)
response = gin.H{
"records": filteredRecords,
"count": len(filteredRecords),
"load_type": loadType,
}
}
// 自动检测是否有GPU数据并附加到响应中
if loadType == "" || loadType == "all" || loadType == "gpu" {
gpuRecords, err := records.GetGPURecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err == nil && len(gpuRecords) > 0 {
// 按设备索引分组数据,构建简化的设备结构
gpuDevices := make(map[string]interface{})
for _, record := range gpuRecords {
deviceKey := strconv.Itoa(record.DeviceIndex)
// 如果设备还没有初始化,创建设备信息
if gpuDevices[deviceKey] == nil {
gpuDevices[deviceKey] = gin.H{
"device_index": record.DeviceIndex,
"device_name": record.DeviceName,
"records": []models.GPURecord{},
}
}
// 添加记录到设备
device := gpuDevices[deviceKey].(gin.H)
records := device["records"].([]models.GPURecord)
device["records"] = append(records, record)
gpuDevices[deviceKey] = device
}
// 添加优化后的GPU数据结构到响应
response["gpu_devices"] = gpuDevices
response["has_gpu_data"] = true
} else {
response["has_gpu_data"] = false
}
}
api.RespondSuccess(c, response)
}
// GET query: uuid string, hours int
// filterRecordsByLoadType 根据 load_type 过滤记录,只返回相关字段
func filterRecordsByLoadType(records []models.Record, loadType string) []gin.H {
filteredRecords := make([]gin.H, 0, len(records))
for _, r := range records {
record := gin.H{
"client": r.Client,
"time": r.Time,
}
switch loadType {
case "cpu":
record["cpu"] = r.Cpu
case "gpu":
record["gpu"] = r.Gpu
case "ram":
record["ram"] = r.Ram
record["ram_total"] = r.RamTotal
if r.RamTotal > 0 {
record["ram_percent"] = float32(r.Ram) / float32(r.RamTotal) * 100
}
case "swap":
record["swap"] = r.Swap
record["swap_total"] = r.SwapTotal
if r.SwapTotal > 0 {
record["swap_percent"] = float32(r.Swap) / float32(r.SwapTotal) * 100
}
case "load":
record["load"] = r.Load
case "temp":
record["temp"] = r.Temp
case "disk":
record["disk"] = r.Disk
record["disk_total"] = r.DiskTotal
if r.DiskTotal > 0 {
record["disk_percent"] = float32(r.Disk) / float32(r.DiskTotal) * 100
}
case "network":
record["net_in"] = r.NetIn
record["net_out"] = r.NetOut
record["net_total_up"] = r.NetTotalUp
record["net_total_down"] = r.NetTotalDown
case "process":
record["process"] = r.Process
case "connections":
record["connections"] = r.Connections
record["connections_udp"] = r.ConnectionsUdp
record["connections_tcp"] = r.Connections - r.ConnectionsUdp
}
filteredRecords = append(filteredRecords, record)
}
return filteredRecords
}
// GET query: uuid string, task_id int, hours int
// 支持三种查询模式:
// 1. 仅 uuid - 获取该客户端的所有 ping 任务记录
// 2. 仅 task_id - 获取该任务的所有客户端记录
// 3. uuid + task_id - 获取特定客户端在特定任务的记录
func GetPingRecords(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
type RecordsResp struct {
TaskId uint `json:"task_id"`
Time string `json:"time"`
Value int `json:"value"`
}
type Resp struct {
Count int `json:"count"`
Records []RecordsResp `json:"records"`
Tasks []gin.H `json:"tasks,omitempty"` // 任务列表
}
taskIdStr := c.Query("task_id")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
// 必须提供 uuid 或 task_id 其中至少一个
if uuid == "" && taskIdStr == "" {
api.RespondError(c, 400, "UUID or task_id is required")
return
}
// 登录状态检查
isLogin := false
session, _ := c.Cookie("session_token")
_, err := accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
type RecordsResp struct {
TaskId uint `json:"task_id,omitempty"`
Time string `json:"time"`
Value int `json:"value"`
Client string `json:"client,omitempty"`
}
type ClientBasicInfo struct {
Client string `json:"client"`
Loss float64 `json:"loss"`
Min int `json:"min"`
Max int `json:"max"`
}
type Resp struct {
Count int `json:"count"`
BasicInfo []ClientBasicInfo `json:"basic_info,omitempty"`
Records []RecordsResp `json:"records"`
Tasks []gin.H `json:"tasks,omitempty"`
}
var records []models.PingRecord
hiddenMap := map[string]bool{}
response := &Resp{
Count: 0,
Records: []RecordsResp{},
}
// 仅在未登录时需要 Hidden 信息做过滤
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if uuid != "" {
if hiddenMap[uuid] {
api.RespondSuccess(c, response) // 对于尝试获取隐藏uuid一键哈气
return
}
}
}
hours := c.Query("hours")
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
hoursInt = 4
}
records, err := tasks.GetPingRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
endTime := time.Now()
startTime := endTime.Add(-time.Duration(hoursInt) * time.Hour)
// 解析 task_id,支持同时传入 uuid 和 task_id
taskId := -1
if taskIdStr != "" {
taskId, err = strconv.Atoi(taskIdStr)
if err != nil {
api.RespondError(c, 400, "Invalid task_id parameter")
return
}
}
// 查询记录,现在支持 uuid + task_id 组合查询
records, err = tasks.GetPingRecords(uuid, taskId, startTime, endTime)
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
api.RespondError(c, 500, "Failed to fetch ping records: "+err.Error())
return
}
response := &Resp{
Count: len(records),
Records: []RecordsResp{},
}
// 用于统计每个客户端的信息(按 task_id 查询时使用)
clientStats := make(map[string]struct {
total int
loss int
min int
max int
})
for _, r := range records {
response.Records = append(response.Records, RecordsResp{
TaskId: r.TaskId,
Time: r.Time.ToTime().Format(time.RFC3339),
Value: r.Value,
})
if r.Client != "" && !isLogin {
if hiddenMap[r.Client] {
continue // 跳过隐藏的节点
}
}
toTime := r.Time.ToTime()
rec := RecordsResp{
Time: toTime.Format(time.RFC3339),
Value: r.Value,
}
rec.Client = r.Client
stats := clientStats[r.Client]
stats.total++
if r.Value < 0 {
stats.loss++
} else {
if stats.min == 0 || r.Value < stats.min {
stats.min = r.Value
}
if r.Value > stats.max {
stats.max = r.Value
}
}
clientStats[r.Client] = stats
rec.TaskId = r.TaskId
response.Records = append(response.Records, rec)
}
if len(records) > 0 {
// 获取当前属于该 uuid 的 pingTasks
// 返回 BasicInfo - 按客户端分组的统计信息
// 在以下情况下特别有用:
// 1. 仅 task_id 查询 - 查看该任务下所有客户端的表现
// 2. uuid + task_id 查询 - 查看特定客户端在特定任务的表现
if len(clientStats) > 0 {
response.BasicInfo = make([]ClientBasicInfo, 0, len(clientStats))
for client, stats := range clientStats {
if client != "" && !isLogin {
if hiddenMap[client] {
continue // 跳过隐藏的节点
}
}
loss := float64(0)
if stats.total > 0 {
loss = float64(stats.loss) / float64(stats.total) * 100
}
response.BasicInfo = append(response.BasicInfo, ClientBasicInfo{
Client: client,
Loss: loss,
Min: stats.min,
Max: stats.max,
})
}
// 如果同时指定了 uuid 和 task_id,BasicInfo 应该只有一条记录
// 这种情况下可以在响应中标记查询模式
if uuid != "" && taskId != -1 && len(response.BasicInfo) == 1 {
// 这是精确查询模式
}
}
// 优化后的任务信息返回逻辑
// 1. uuid != "" - 返回该客户端参与的所有任务信息
// 2. uuid != "" && taskId != -1 - 返回该客户端在指定任务的信息
// 3. taskId != -1 && uuid == "" - 返回该任务的所有客户端统计(通过 BasicInfo)
if uuid != "" || taskId != -1 {
// 获取所有 pingTasks
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
return
}
taskIdSet := make(map[uint]struct{})
for _, r := range records {
taskIdSet[r.TaskId] = struct{}{}
}
tasksList := make([]gin.H, 0, len(pingTasks))
for _, t := range pingTasks {
// 只保留分配给该 uuid 的任务
found := false
for _, client := range t.Clients {
if client == uuid {
found = true
break
// 如果指定了 taskId,只处理该任务
if taskId != -1 {
if t.Id != uint(taskId) {
continue
}
}
if !found {
continue
// 如果指定了 uuid,检查任务是否分配给该客户端
if uuid != "" {
found := slices.Contains(t.Clients, uuid)
if !found {
continue
}
}
// 只返回有记录的任务
if _, ok := taskIdSet[t.Id]; ok {
tasksList = append(tasksList, gin.H{
"id": t.Id,
"name": t.Name,
"interval": t.Interval,
})
// 计算该任务的丢包率和延迟统计
totalCount := 0
lossCount := 0
minLatency := 0
maxLatency := 0
avgLatency := 0
sumLatency := 0
validCount := 0
for _, r := range records {
// 根据查询模式过滤记录
if r.TaskId != t.Id {
continue
}
// 如果同时指定了 uuid 和 task_id,只统计该客户端的记录
if uuid != "" && r.Client != uuid {
continue
}
totalCount++
if r.Value < 0 {
lossCount++
} else {
validCount++
sumLatency += r.Value
if minLatency == 0 || r.Value < minLatency {
minLatency = r.Value
}
if r.Value > maxLatency {
maxLatency = r.Value
}
}
}
var lossRate float64 = 0
if totalCount > 0 {
lossRate = float64(lossCount) / float64(totalCount) * 100
}
if validCount > 0 {
avgLatency = sumLatency / validCount
}
taskInfo := gin.H{
"id": t.Id,
"name": t.Name,
"type": t.Type,
"interval": t.Interval,
"loss": lossRate,
"min": minLatency,
"max": maxLatency,
"avg": avgLatency,
"total": totalCount,
}
// 如果是仅 task_id 查询,添加客户端列表信息
if uuid == "" && taskId != -1 {
taskInfo["clients"] = t.Clients
}
tasksList = append(tasksList, taskInfo)
}
response.Tasks = tasksList
}
response.Count = len(response.Records) // 计算最后结果保持计数一致
api.RespondSuccess(c, response)
}
+38
View File
@@ -0,0 +1,38 @@
package task
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
)
type PublicPingTask struct {
Id uint `json:"id"`
Name string `json:"name"`
Clients []string `json:"clients"`
Type string `json:"type"`
Interval int `json:"interval"`
}
func GetPublicPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
publicTasks := make([]PublicPingTask, len(tasks))
for i, task := range tasks {
publicTasks[i] = PublicPingTask{
Id: task.Id,
Name: task.Name,
Clients: task.Clients,
Type: task.Type,
Interval: task.Interval,
}
}
api.RespondSuccess(c, publicTasks)
}
+1 -3
View File
@@ -30,9 +30,7 @@ func RequestTerminal(c *gin.Context) {
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: ws.CheckOrigin,
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
+122
View File
@@ -0,0 +1,122 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/ws"
)
func GetClients(c *gin.Context) {
// 升级到ws
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
cfg, _ := config.Get()
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
if cfg.AllowCors {
return true
}
return ws.CheckOrigin(r)
},
}
// Upgrade the HTTP connection to a WebSocket connection
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
defer conn.Close()
// 初始化用户信息
var (
isLogin = false
hiddenMap = map[string]bool{}
session, _ = c.Cookie("session_token")
)
// 登录状态检查
_, err = accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
}
// 请求
for {
var resp struct {
Online []string `json:"online"` // 已建立连接的客户端uuid列表
Data map[string]common.Report `json:"data"` // 最后上报的数据
}
resp.Online = []string{}
resp.Data = map[string]common.Report{}
_, data, err := conn.ReadMessage()
if err != nil {
//log.Println("Error reading message:", err)
return
}
message := string(data)
uuID := ""
if message != "get" { // 非请求全部内容
if strings.HasPrefix(message, "get ") {
uuID = strings.TrimSpace(strings.TrimPrefix(message, "get "))
} else {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid message"})
continue
}
}
// 在线客户端uuid列表(WebSocket 与非 WebSocket)
for _, key := range ws.GetAllOnlineUUIDs() {
if !isLogin && hiddenMap[key] {
continue
}
if uuID != "" && key != uuID {
continue
}
resp.Online = append(resp.Online, key)
}
//过往节点数据信息
for key, report := range ws.GetLatestReport() {
if !isLogin && hiddenMap[key] {
continue
}
if uuID != "" && key != uuID {
continue
}
report.UUID = "" // 不暴露 uuid
if report.CPU.Usage == 0 {
report.CPU.Usage = 0.01
}
resp.Data[key] = *report
}
err = conn.WriteJSON(gin.H{"status": "success", "data": resp})
if err != nil {
return
}
}
}
+404
View File
@@ -0,0 +1,404 @@
package cmd
import (
"context"
"errors"
"io"
"log"
"math"
"net"
"strings"
"sync"
"time"
apiClient "github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/compat/nezha/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/metadata"
"gorm.io/gorm/clause"
)
// [Deprecated] Use StartNezhaCompat instead.
func StartNezhaCompatServer(addr string) error {
boot := uint64(time.Now().Unix())
lis, err := net.Listen("tcp", addr)
if err != nil {
return err
}
srv := &nezhaCompatServer{bootTime: boot}
// Interceptors for metadata auth presence (lightweight; validation is inside handlers too)
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
return handler(ctx, req)
}
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
return handler(srvIface, ss)
}
// Enable gRPC keepalive to tolerate long-idle streams
gs := grpc.NewServer(
grpc.UnaryInterceptor(unary),
grpc.StreamInterceptor(stream),
// Keepalive: allow client pings and keep connections stable without app traffic
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 2 * time.Minute,
Timeout: 20 * time.Second,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 20 * time.Second,
PermitWithoutStream: true,
}),
)
proto.RegisterNezhaServiceServer(gs, srv)
log.Printf("Nezha compat gRPC listening on %s", addr)
return gs.Serve(lis)
}
// ---- Manual start/stop support ----
var (
nezhaSrv *grpc.Server
nezhaLis net.Listener
nezhaOnceM sync.Mutex
)
// StartNezhaCompat starts the Nezha compatible gRPC server asynchronously.
// Returns error if already started.
func StartNezhaCompat(addr string) error {
nezhaOnceM.Lock()
defer nezhaOnceM.Unlock()
if nezhaSrv != nil {
return errors.New("nezha compat server already started")
}
lis, err := net.Listen("tcp", addr)
if err != nil {
return err
}
boot := uint64(time.Now().Unix())
sImpl := &nezhaCompatServer{bootTime: boot}
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
return handler(ctx, req)
}
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
return handler(srvIface, ss)
}
gs := grpc.NewServer(
grpc.UnaryInterceptor(unary),
grpc.StreamInterceptor(stream),
grpc.KeepaliveParams(keepalive.ServerParameters{Time: 2 * time.Minute, Timeout: 20 * time.Second}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 20 * time.Second, PermitWithoutStream: true}),
)
proto.RegisterNezhaServiceServer(gs, sImpl)
nezhaSrv = gs
nezhaLis = lis
go func() {
if err := gs.Serve(lis); err != nil {
log.Printf("Nezha compat gRPC server stopped: %v", err)
}
}()
log.Printf("Nezha compat gRPC started on %s", addr)
return nil
}
// StopNezhaCompat stops the server if running.
func StopNezhaCompat() error {
nezhaOnceM.Lock()
defer nezhaOnceM.Unlock()
if nezhaSrv == nil {
return errors.New("nezha compat server not running")
}
// 强制立即断开所有连接与流,不等待在途 RPC 完成。
nezhaSrv.Stop()
// Listener close (Serve already returns after GracefulStop, but close to be explicit)
if nezhaLis != nil {
_ = nezhaLis.Close()
}
nezhaSrv = nil
nezhaLis = nil
log.Printf("Nezha compat gRPC stopped")
return nil
}
type nezhaCompatServer struct {
proto.UnimplementedNezhaServiceServer
bootTime uint64
}
// authentication helpers
func getAuth(ctx context.Context) (uuid string, secret string, err error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return "", "", errors.New("missing metadata")
}
getFirst := func(key string) string {
vals := md.Get(key)
if len(vals) > 0 {
return vals[0]
}
return ""
}
uuid = getFirst("client_uuid")
secret = getFirst("client_secret")
if uuid == "" || secret == "" {
return "", "", errors.New("unauthorized: missing client_uuid/client_secret")
}
return uuid, secret, nil
}
// ReportSystemInfo: upsert static host info
func (s *nezhaCompatServer) ReportSystemInfo(ctx context.Context, host *proto.Host) (*proto.Receipt, error) {
uuid, secret, err := getAuth(ctx)
if err != nil {
return nil, err
}
if err := upsertClientFromHost(uuid, secret, host); err != nil {
return nil, err
}
return &proto.Receipt{Proced: true}, nil
}
// ReportSystemInfo2: same as above but returns dashboard boot time
func (s *nezhaCompatServer) ReportSystemInfo2(ctx context.Context, host *proto.Host) (*proto.Uint64Receipt, error) {
uuid, secret, err := getAuth(ctx)
if err != nil {
return nil, err
}
if err := upsertClientFromHost(uuid, secret, host); err != nil {
return nil, err
}
return &proto.Uint64Receipt{Data: s.bootTime}, nil
}
// ReportSystemState: ingest time-series records
func (s *nezhaCompatServer) ReportSystemState(stream proto.NezhaService_ReportSystemStateServer) error {
ctx := stream.Context()
uuid, _, err := getAuth(ctx)
if err != nil {
return err
}
// presence start
connID := time.Now().UnixNano()
ws.SetPresence(uuid, connID, true)
go notifier.OnlineNotification(uuid, connID)
defer func() {
ws.SetPresence(uuid, connID, false)
notifier.OfflineNotification(uuid, connID)
}()
for {
st, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// refresh presence TTL on every frame
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
if err := ingestState(uuid, st); err != nil {
// still ack to avoid client stuck; log error
log.Printf("Nezha ingest state error: %v", err)
}
if err := stream.Send(&proto.Receipt{Proced: true}); err != nil {
return err
}
}
}
// RequestTask: do not dispatch tasks, just drain results to avoid Unimplemented
func (s *nezhaCompatServer) RequestTask(stream proto.NezhaService_RequestTaskServer) error {
ctx := stream.Context()
uuid, _, err := getAuth(ctx)
if err != nil {
return err
}
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
connID := time.Now().UnixNano()
ws.SetPresence(uuid, connID, true)
defer ws.SetPresence(uuid, connID, false)
// receive results in background
recvErr := make(chan error, 1)
go func() {
for {
_, rerr := stream.Recv()
if rerr == io.EOF {
recvErr <- nil
return
}
if rerr != nil {
recvErr <- rerr
return
}
// refresh presence TTL when result received
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
}
}()
// send heartbeat tasks periodically
for {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-recvErr:
return err
case <-ticker.C:
if err := stream.Send(&proto.Task{}); err != nil {
return err
}
}
}
}
// Unimplemented methods intentionally left as default (IOStream, ReportGeoIP)
// upsertClientFromHost maps Host into models.Client and upserts by UUID
func upsertClientFromHost(uuid, secret string, h *proto.Host) error {
db := dbcore.GetDBInstance()
now := models.FromTime(time.Now())
// token guard: if existing record has different token, reject
var exist models.Client
if err := db.Where("uuid = ?", uuid).First(&exist).Error; err == nil {
if exist.Token != "" && exist.Token != secret {
return errors.New("unauthorized: token mismatch")
}
}
cpuName := ""
if len(h.Cpu) > 0 {
cpuName = h.Cpu[0]
}
gpuName := strings.Join(h.Gpu, "; ")
osName := h.Platform
if h.PlatformVersion != "" {
osName = h.Platform + " " + h.PlatformVersion
}
// clamp uint64 to int64 safely
clamp := func(v uint64) int64 {
if v > uint64(math.MaxInt64) {
return math.MaxInt64
}
return int64(v)
}
c := models.Client{
UUID: uuid,
Token: secret,
Name: "nezha_" + uuid[0:8],
CpuName: cpuName,
Arch: h.Arch,
CpuCores: len(h.Cpu),
OS: osName,
KernelVersion: h.PlatformVersion,
Virtualization: h.Virtualization,
GpuName: gpuName,
MemTotal: clamp(h.MemTotal),
SwapTotal: clamp(h.SwapTotal),
DiskTotal: clamp(h.DiskTotal),
Version: h.Version,
UpdatedAt: now,
}
// Upsert by UUID; don't override existing Token if already set
updates := map[string]interface{}{
"cpu_name": c.CpuName,
"arch": c.Arch,
"cpu_cores": c.CpuCores,
"os": c.OS,
"kernel_version": c.KernelVersion,
"virtualization": c.Virtualization,
"gpu_name": c.GpuName,
"mem_total": c.MemTotal,
"swap_total": c.SwapTotal,
"disk_total": c.DiskTotal,
"version": c.Version,
"updated_at": time.Now(),
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(updates),
}).Create(&c).Error
}
// ingestState maps Nezha State into common.Report then saves a Record
func ingestState(uuid string, st *proto.State) error {
// we may need totals from client
db := dbcore.GetDBInstance()
var client models.Client
if err := db.Where("uuid = ?", uuid).First(&client).Error; err != nil {
// If missing, create with minimal defaults to avoid failing ingestion
client = models.Client{UUID: uuid, Token: "", Name: "nezha_" + uuid[0:8]}
auditlog.EventLog("info", "auto created client "+client.Name)
_ = db.Create(&client).Error
}
rep := common.Report{
CPU: common.CPUReport{Usage: st.Cpu},
Ram: common.RamReport{Total: client.MemTotal, Used: int64(st.MemUsed)},
Swap: common.RamReport{Total: client.SwapTotal, Used: int64(st.SwapUsed)},
Load: common.LoadReport{Load1: st.Load1, Load5: st.Load5, Load15: st.Load15},
Disk: common.DiskReport{Total: client.DiskTotal, Used: int64(st.DiskUsed)},
Network: common.NetworkReport{
Up: int64(st.NetOutSpeed),
Down: int64(st.NetInSpeed),
TotalUp: int64(st.NetOutTransfer),
TotalDown: int64(st.NetInTransfer),
},
Uptime: int64(st.Uptime),
Process: int(st.ProcessCount),
UpdatedAt: time.Now(),
}
// 更新实时缓存供前端使用
ws.SetLatestReport(uuid, &rep)
// 写入内存缓存,入库交由定时聚合任务处理
return apiClient.SaveClientReport(uuid, rep)
}
// ReportGeoIP: 保存 Agent 上报的 IP,并回填国家码/面板启动时间
func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*proto.GeoIP, error) {
uuid, _, err := getAuth(ctx)
if err != nil {
return nil, err
}
updates := map[string]interface{}{
"updated_at": time.Now(),
}
var iso string
if in != nil && in.Ip != nil {
if v4 := strings.TrimSpace(in.Ip.Ipv4); v4 != "" {
updates["ipv4"] = v4
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ip := net.ParseIP(v4); ip != nil {
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
iso = gi.ISOCode
}
}
}
}
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
updates["ipv6"] = v6
if iso == "" { // 优先使用 v4 的国家码
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ip := net.ParseIP(v6); ip != nil {
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
iso = gi.ISOCode
}
}
}
}
}
}
if iso != "" {
// UI 使用旗帜 emoji
updates["region"] = geoip.GetRegionUnicodeEmoji(iso)
}
if len(updates) > 0 {
_ = dbcore.GetDBInstance().Model(&models.Client{}).Where("uuid = ?", uuid).Updates(updates).Error
}
// 回写 GeoIP(包含国家码与面板启动时间)
resp := &proto.GeoIP{Use6: in.GetUse6(), Ip: in.GetIp(), CountryCode: iso, DashboardBootTime: s.bootTime}
return resp, nil
}
+42 -13
View File
@@ -20,7 +20,10 @@ import (
"github.com/komari-monitor/komari/api/admin/test"
"github.com/komari-monitor/komari/api/admin/update"
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/jsonRpc"
public_api "github.com/komari-monitor/komari/api/public"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/api/task"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
@@ -35,10 +38,10 @@ import (
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
logutil "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/ws"
"github.com/spf13/cobra"
)
@@ -83,16 +86,17 @@ func RunServer() {
go DoScheduledWork()
go messageSender.Initialize()
// oidcInit
oidcProvider, err := database.GetOidcConfigByName(conf.OAuthProvider)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
}
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
if err != nil {
log.Printf("Failed to load OIDC provider: %v", err)
} else {
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
go oauth.Initialize()
if conf.NezhaCompatEnabled {
go func() {
if err := StartNezhaCompat(conf.NezhaCompatListen); err != nil {
log.Printf("Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
}
}()
}
config.Subscribe(func(event config.ConfigEvent) {
if event.New.OAuthProvider != event.Old.OAuthProvider {
oidcProvider, err := database.GetOidcConfigByName(event.New.OAuthProvider)
@@ -109,6 +113,19 @@ func RunServer() {
if event.New.NotificationMethod != event.Old.NotificationMethod {
messageSender.Initialize()
}
if event.New.NezhaCompatEnabled != event.Old.NezhaCompatEnabled {
if event.New.NezhaCompatEnabled {
if err := StartNezhaCompat(event.New.NezhaCompatListen); err != nil {
log.Printf("start Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
}
} else {
if err := StopNezhaCompat(); err != nil {
log.Printf("stop Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
}
}
}
})
// 初始化 cloudflared
@@ -119,7 +136,9 @@ func RunServer() {
}
}
r := gin.Default()
r := gin.New()
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
// 动态 CORS 中间件
@@ -164,7 +183,7 @@ func RunServer() {
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", ws.GetClients)
r.GET("/api/clients", api.GetClients)
r.GET("/api/nodes", api.GetNodesInformation)
r.GET("/api/public", api.GetPublicSettings)
r.GET("/api/oauth", api.OAuth)
@@ -175,7 +194,12 @@ func RunServer() {
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
r.GET("/api/task/ping", task.GetPublicPingTasks)
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
r.GET("/api/mjpeg_live", public_api.MjpegLiveHandler)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
@@ -230,6 +254,8 @@ func RunServer() {
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
clientGroup := adminAuthrized.Group("/client")
@@ -322,6 +348,7 @@ func RunServer() {
Addr: flags.Listen,
Handler: r,
}
log.Printf("Starting server on %s ...", flags.Listen)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
@@ -371,9 +398,9 @@ func DoScheduledWork() {
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
go notifier.CheckExpireScheduledWork()
for {
cfg, _ := config.Get()
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
@@ -387,6 +414,8 @@ func DoScheduledWork() {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
// 每分钟检查一次流量提醒
go notifier.CheckTraffic()
}
}
+18
View File
@@ -69,6 +69,7 @@ type Report struct {
Disk DiskReport `json:"disk"`
Network NetworkReport `json:"network"`
Connections ConnectionsReport `json:"connections"`
GPU *GPUDetailReport `json:"gpu,omitempty"` // 新增GPU详细信息
Uptime int64 `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
@@ -83,6 +84,23 @@ type CPUReport struct {
Usage float64 `json:"usage,omitempty"`
}
// GPUDetailReport 详细GPU信息报告
type GPUDetailReport struct {
Count int `json:"count"` // GPU数量
AverageUsage float64 `json:"average_usage"` // 平均使用率
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
}
// GPUDeviceInfo 单个GPU设备信息
type GPUDeviceInfo struct {
Name string `json:"name"` // GPU型号名称
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
Utilization float64 `json:"utilization"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// 保持向后兼容的旧GPUReport结构
type GPUReport struct {
Name string `json:"name,omitempty"`
Usage float64 `json:"usage,omitempty"`
+83
View File
@@ -0,0 +1,83 @@
syntax = "proto3";
package proto;
option go_package = "github.com/komari-monitor/komari/compat/nezha/proto";
service NezhaService {
rpc ReportSystemState(stream State) returns (stream Receipt) {}
rpc ReportSystemInfo(Host) returns (Receipt) {}
rpc RequestTask(stream TaskResult) returns (stream Task) {}
rpc IOStream(stream IOStreamData) returns (stream IOStreamData) {}
rpc ReportGeoIP(GeoIP) returns (GeoIP) {}
rpc ReportSystemInfo2(Host) returns (Uint64Receipt) {}
}
message Host {
string platform = 1;
string platform_version = 2;
repeated string cpu = 3;
uint64 mem_total = 4;
uint64 disk_total = 5;
uint64 swap_total = 6;
string arch = 7;
string virtualization = 8;
uint64 boot_time = 9;
string version = 10;
repeated string gpu = 11;
}
message State {
double cpu = 1;
uint64 mem_used = 2;
uint64 swap_used = 3;
uint64 disk_used = 4;
uint64 net_in_transfer = 5;
uint64 net_out_transfer = 6;
uint64 net_in_speed = 7;
uint64 net_out_speed = 8;
uint64 uptime = 9;
double load1 = 10;
double load5 = 11;
double load15 = 12;
uint64 tcp_conn_count = 13;
uint64 udp_conn_count = 14;
uint64 process_count = 15;
repeated State_SensorTemperature temperatures = 16;
repeated double gpu = 17;
}
message State_SensorTemperature {
string name = 1;
double temperature = 2;
}
message Task {
uint64 id = 1;
uint64 type = 2;
string data = 3;
}
message TaskResult {
uint64 id = 1;
uint64 type = 2;
float delay = 3;
string data = 4;
bool successful = 5;
}
message Receipt { bool proced = 1; }
message Uint64Receipt { uint64 data = 1; }
message IOStreamData { bytes data = 1; }
message GeoIP {
bool use6 = 1;
IP ip = 2;
string country_code = 3;
uint64 dashboard_boot_time = 4;
}
message IP {
string ipv4 = 1;
string ipv6 = 2;
}
File diff suppressed because it is too large. Load diff
+374
View File
@@ -0,0 +1,374 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
package proto
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
const _ = grpc.SupportPackageIsVersion7
// NezhaServiceClient is the client API for NezhaService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type NezhaServiceClient interface {
ReportSystemState(ctx context.Context, opts ...grpc.CallOption) (NezhaService_ReportSystemStateClient, error)
ReportSystemInfo(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Receipt, error)
RequestTask(ctx context.Context, opts ...grpc.CallOption) (NezhaService_RequestTaskClient, error)
IOStream(ctx context.Context, opts ...grpc.CallOption) (NezhaService_IOStreamClient, error)
ReportGeoIP(ctx context.Context, in *GeoIP, opts ...grpc.CallOption) (*GeoIP, error)
ReportSystemInfo2(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Uint64Receipt, error)
}
type nezhaServiceClient struct {
cc grpc.ClientConnInterface
}
func NewNezhaServiceClient(cc grpc.ClientConnInterface) NezhaServiceClient {
return &nezhaServiceClient{cc}
}
func (c *nezhaServiceClient) ReportSystemState(ctx context.Context, opts ...grpc.CallOption) (NezhaService_ReportSystemStateClient, error) {
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[0], "/proto.NezhaService/ReportSystemState", opts...)
if err != nil {
return nil, err
}
x := &nezhaServiceReportSystemStateClient{stream}
return x, nil
}
type NezhaService_ReportSystemStateClient interface {
Send(*State) error
Recv() (*Receipt, error)
grpc.ClientStream
}
type nezhaServiceReportSystemStateClient struct {
grpc.ClientStream
}
func (x *nezhaServiceReportSystemStateClient) Send(m *State) error {
return x.ClientStream.SendMsg(m)
}
func (x *nezhaServiceReportSystemStateClient) Recv() (*Receipt, error) {
m := new(Receipt)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *nezhaServiceClient) ReportSystemInfo(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Receipt, error) {
out := new(Receipt)
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportSystemInfo", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *nezhaServiceClient) RequestTask(ctx context.Context, opts ...grpc.CallOption) (NezhaService_RequestTaskClient, error) {
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[1], "/proto.NezhaService/RequestTask", opts...)
if err != nil {
return nil, err
}
x := &nezhaServiceRequestTaskClient{stream}
return x, nil
}
type NezhaService_RequestTaskClient interface {
Send(*TaskResult) error
Recv() (*Task, error)
grpc.ClientStream
}
type nezhaServiceRequestTaskClient struct {
grpc.ClientStream
}
func (x *nezhaServiceRequestTaskClient) Send(m *TaskResult) error {
return x.ClientStream.SendMsg(m)
}
func (x *nezhaServiceRequestTaskClient) Recv() (*Task, error) {
m := new(Task)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *nezhaServiceClient) IOStream(ctx context.Context, opts ...grpc.CallOption) (NezhaService_IOStreamClient, error) {
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[2], "/proto.NezhaService/IOStream", opts...)
if err != nil {
return nil, err
}
x := &nezhaServiceIOStreamClient{stream}
return x, nil
}
type NezhaService_IOStreamClient interface {
Send(*IOStreamData) error
Recv() (*IOStreamData, error)
grpc.ClientStream
}
type nezhaServiceIOStreamClient struct {
grpc.ClientStream
}
func (x *nezhaServiceIOStreamClient) Send(m *IOStreamData) error {
return x.ClientStream.SendMsg(m)
}
func (x *nezhaServiceIOStreamClient) Recv() (*IOStreamData, error) {
m := new(IOStreamData)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *nezhaServiceClient) ReportGeoIP(ctx context.Context, in *GeoIP, opts ...grpc.CallOption) (*GeoIP, error) {
out := new(GeoIP)
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportGeoIP", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *nezhaServiceClient) ReportSystemInfo2(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Uint64Receipt, error) {
out := new(Uint64Receipt)
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportSystemInfo2", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// NezhaServiceServer is the server API for NezhaService service.
// All implementations must embed UnimplementedNezhaServiceServer
// for forward compatibility
type NezhaServiceServer interface {
ReportSystemState(NezhaService_ReportSystemStateServer) error
ReportSystemInfo(context.Context, *Host) (*Receipt, error)
RequestTask(NezhaService_RequestTaskServer) error
IOStream(NezhaService_IOStreamServer) error
ReportGeoIP(context.Context, *GeoIP) (*GeoIP, error)
ReportSystemInfo2(context.Context, *Host) (*Uint64Receipt, error)
mustEmbedUnimplementedNezhaServiceServer()
}
// UnimplementedNezhaServiceServer must be embedded to have forward compatible implementations.
type UnimplementedNezhaServiceServer struct {
}
func (UnimplementedNezhaServiceServer) ReportSystemState(NezhaService_ReportSystemStateServer) error {
return status.Errorf(codes.Unimplemented, "method ReportSystemState not implemented")
}
func (UnimplementedNezhaServiceServer) ReportSystemInfo(context.Context, *Host) (*Receipt, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportSystemInfo not implemented")
}
func (UnimplementedNezhaServiceServer) RequestTask(NezhaService_RequestTaskServer) error {
return status.Errorf(codes.Unimplemented, "method RequestTask not implemented")
}
func (UnimplementedNezhaServiceServer) IOStream(NezhaService_IOStreamServer) error {
return status.Errorf(codes.Unimplemented, "method IOStream not implemented")
}
func (UnimplementedNezhaServiceServer) ReportGeoIP(context.Context, *GeoIP) (*GeoIP, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportGeoIP not implemented")
}
func (UnimplementedNezhaServiceServer) ReportSystemInfo2(context.Context, *Host) (*Uint64Receipt, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportSystemInfo2 not implemented")
}
func (UnimplementedNezhaServiceServer) mustEmbedUnimplementedNezhaServiceServer() {}
// UnsafeNezhaServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to NezhaServiceServer will
// result in compilation errors.
type UnsafeNezhaServiceServer interface {
mustEmbedUnimplementedNezhaServiceServer()
}
func RegisterNezhaServiceServer(s *grpc.Server, srv NezhaServiceServer) {
s.RegisterService(&_NezhaService_serviceDesc, srv)
}
func _NezhaService_ReportSystemState_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(NezhaServiceServer).ReportSystemState(&nezhaServiceReportSystemStateServer{stream})
}
type NezhaService_ReportSystemStateServer interface {
Send(*Receipt) error
Recv() (*State, error)
grpc.ServerStream
}
type nezhaServiceReportSystemStateServer struct {
grpc.ServerStream
}
func (x *nezhaServiceReportSystemStateServer) Send(m *Receipt) error {
return x.ServerStream.SendMsg(m)
}
func (x *nezhaServiceReportSystemStateServer) Recv() (*State, error) {
m := new(State)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func _NezhaService_ReportSystemInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Host)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NezhaServiceServer).ReportSystemInfo(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/proto.NezhaService/ReportSystemInfo",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NezhaServiceServer).ReportSystemInfo(ctx, req.(*Host))
}
return interceptor(ctx, in, info, handler)
}
func _NezhaService_RequestTask_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(NezhaServiceServer).RequestTask(&nezhaServiceRequestTaskServer{stream})
}
type NezhaService_RequestTaskServer interface {
Send(*Task) error
Recv() (*TaskResult, error)
grpc.ServerStream
}
type nezhaServiceRequestTaskServer struct {
grpc.ServerStream
}
func (x *nezhaServiceRequestTaskServer) Send(m *Task) error {
return x.ServerStream.SendMsg(m)
}
func (x *nezhaServiceRequestTaskServer) Recv() (*TaskResult, error) {
m := new(TaskResult)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func _NezhaService_IOStream_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(NezhaServiceServer).IOStream(&nezhaServiceIOStreamServer{stream})
}
type NezhaService_IOStreamServer interface {
Send(*IOStreamData) error
Recv() (*IOStreamData, error)
grpc.ServerStream
}
type nezhaServiceIOStreamServer struct {
grpc.ServerStream
}
func (x *nezhaServiceIOStreamServer) Send(m *IOStreamData) error {
return x.ServerStream.SendMsg(m)
}
func (x *nezhaServiceIOStreamServer) Recv() (*IOStreamData, error) {
m := new(IOStreamData)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func _NezhaService_ReportGeoIP_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GeoIP)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NezhaServiceServer).ReportGeoIP(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/proto.NezhaService/ReportGeoIP",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NezhaServiceServer).ReportGeoIP(ctx, req.(*GeoIP))
}
return interceptor(ctx, in, info, handler)
}
func _NezhaService_ReportSystemInfo2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Host)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NezhaServiceServer).ReportSystemInfo2(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/proto.NezhaService/ReportSystemInfo2",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NezhaServiceServer).ReportSystemInfo2(ctx, req.(*Host))
}
return interceptor(ctx, in, info, handler)
}
var _NezhaService_serviceDesc = grpc.ServiceDesc{
ServiceName: "proto.NezhaService",
HandlerType: (*NezhaServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ReportSystemInfo",
Handler: _NezhaService_ReportSystemInfo_Handler,
},
{
MethodName: "ReportGeoIP",
Handler: _NezhaService_ReportGeoIP_Handler,
},
{
MethodName: "ReportSystemInfo2",
Handler: _NezhaService_ReportSystemInfo2_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "ReportSystemState",
Handler: _NezhaService_ReportSystemState_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "RequestTask",
Handler: _NezhaService_RequestTask_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "IOStream",
Handler: _NezhaService_IOStream_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "compat/nezha/nezha.proto",
}
+21
View File
@@ -2,11 +2,17 @@ package accounts
import (
"errors"
"fmt"
"net"
"time"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
)
// GetAllSessions 获取所有会话
@@ -33,6 +39,21 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
LoginMethod: login_method,
LatestOnline: models.FromTime(time.Now()),
}
cfg, _ := config.Get()
if cfg.LoginNotification {
ipAddr := net.ParseIP(ip)
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
loc := "unknown"
if ipinfo != nil {
loc = ipinfo.Name
}
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Login,
Time: time.Now(),
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
Emoji: "🔑",
})
}
err := db.Create(&sessionRecord).Error
if err != nil {
+32 -1
View File
@@ -1,6 +1,7 @@
package clients
import (
"math"
"time"
"github.com/komari-monitor/komari/common"
@@ -47,7 +48,7 @@ func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
if cbi.Arch != "" {
updates["arch"] = cbi.Arch
}
if cbi.CpuCores != 0 {
if cbi.CpuCores > 0 || cbi.CpuCores < math.MaxInt-1 {
updates["cpu_cores"] = cbi.CpuCores
}
if cbi.OS != "" {
@@ -120,6 +121,36 @@ func SaveClientInfo(update map[string]interface{}) error {
update["updated_at"] = time.Now()
checkInt64 := func(name string, val float64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
}
return nil
}
verify := func(update map[string]interface{}) error {
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
}
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
return err
}
return nil
}
if err := verify(update); err != nil {
return err
}
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
+118 -2
View File
@@ -3,6 +3,7 @@ package clients
import (
"encoding/json"
"fmt"
"math"
"time"
"github.com/komari-monitor/komari/common"
@@ -51,15 +52,129 @@ func ParseReport(data map[string]interface{}) (report common.Report, err error)
return report, nil
}
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report common.Report) error {
// 防止输入不合理范围
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
return fmt.Errorf("CPU.Usage must be between 0 and 100")
}
if report.Load.Load1 < 0 || report.Load.Load1 > 1000 {
return fmt.Errorf("Load.Load1 must be non-negative, got %.2f", report.Load.Load1)
}
checkFloat64 := func(name string, val float64) error {
if val > math.MaxFloat64-1 || val < -math.MaxFloat64+1 {
return fmt.Errorf("%s value exceeds float64 range: %g", name, val)
}
return nil
}
// [float64] 防止数据溢出
if err := checkFloat64("CPU.Usage", report.CPU.Usage); err != nil {
return err
}
if err := checkFloat64("Load.Load1", report.Load.Load1); err != nil {
return err
}
checkInt64 := func(name string, val int64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, val)
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, val)
}
return nil
}
// [int64] 防止数据溢出
// Ram 验证
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
return err
}
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
return err
}
// Swap 验证
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
return err
}
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
return err
}
// Disk 验证
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
return err
}
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
return err
}
// Network 验证
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
return err
}
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
return err
}
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
return err
}
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
return err
}
// 拒绝所有负数Int
if report.Process < 0 {
return fmt.Errorf("Process must be non-negative: %d", report.Process)
}
if report.Connections.TCP < 0 {
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
}
if report.Connections.UDP < 0 {
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
}
return nil
}
// SaveClientReport 保存客户端报告到 Record 表
func SaveClientReport(clientUUID string, report common.Report) (err error) {
db := dbcore.GetDBInstance()
if err := ReportVerify(report); err != nil {
return fmt.Errorf("failed to save Record: %v", err)
}
// 保存GPU详细记录到独立表
currentTime := time.Now()
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
for idx, gpu := range report.GPU.DetailedInfo {
gpuRecord := models.GPURecord{
Client: clientUUID,
Time: models.FromTime(currentTime),
DeviceIndex: idx,
DeviceName: gpu.Name,
MemTotal: gpu.MemoryTotal,
MemUsed: gpu.MemoryUsed,
Utilization: float32(gpu.Utilization),
Temperature: gpu.Temperature,
}
if err := db.Create(&gpuRecord).Error; err != nil {
return fmt.Errorf("failed to save GPU record: %v", err)
}
}
}
// 计算平均GPU使用率,用于向后兼容
averageGPUUsage := float32(0)
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
averageGPUUsage = float32(report.GPU.AverageUsage)
}
Record := models.Record{
Client: clientUUID,
Time: models.FromTime(time.Now()),
Time: models.FromTime(currentTime),
Cpu: float32(report.CPU.Usage),
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
Gpu: averageGPUUsage, // 使用平均GPU使用率
Ram: report.Ram.Used,
RamTotal: report.Ram.Total,
Swap: report.Swap.Used,
@@ -75,6 +190,7 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
Process: report.Process,
Connections: report.Connections.TCP,
ConnectionsUdp: report.Connections.UDP,
//Uptime: report.Uptime,
}
// 使用事务确保 Record 和 ClientsInfo 一致性
+22 -10
View File
@@ -25,15 +25,18 @@ func Get() (models.Config, error) {
if err := db.First(&config).Error; err != nil {
if err == gorm.ErrRecordNotFound {
config = models.Config{
ID: 1,
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "ip-api",
UpdatedAt: models.FromTime(time.Now()),
CreatedAt: models.FromTime(time.Now()),
ID: 1,
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "ipinfo",
NezhaCompatEnabled: false,
NezhaCompatListen: "",
NotificationTemplate: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
UpdatedAt: models.FromTime(time.Now()),
CreatedAt: models.FromTime(time.Now()),
}
if err := db.Create(&config).Error; err != nil {
log.Fatal("Failed to create default config:", err)
@@ -82,7 +85,16 @@ func Update(cst map[string]interface{}) error {
if newDisablePasswordLogin && !newOAuthEnabled {
return errors.New("at least one login method must be enabled (password/oauth)")
}
// 没绑定账号也不能禁用
if newDisablePasswordLogin {
usr := &models.User{}
if err := db.Model(&models.User{}).First(usr).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve user"))
}
if usr.SSOID == "" {
return errors.New("cannot disable password login when no SSO-bound account exists")
}
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
return errors.Join(err, errors.New("failed to update configuration"))
+210 -10
View File
@@ -1,21 +1,171 @@
package dbcore
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
logutil "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
func zipDirectoryExcluding(srcDir, dstZip string, exclude map[string]struct{}) error {
// 规范化排除路径为绝对路径
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
out, err := os.Create(dstZip)
if err != nil {
return err
}
defer out.Close()
zw := zip.NewWriter(out)
defer zw.Close()
absSrc, _ := filepath.Abs(srcDir)
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 排除 backup.zip 本身
if _, ok := normExclude[path]; ok {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// 计算 zip 内相对路径
rel, err := filepath.Rel(absSrc, path)
if err != nil {
return err
}
// 根目录跳过
if rel == "." {
return nil
}
// 替换为正斜杠
zipName := filepath.ToSlash(rel)
if info.IsDir() {
_, err := zw.Create(zipName + "/")
return err
}
// 普通文件
fh, err := os.Open(path)
if err != nil {
return err
}
w, err := zw.Create(zipName)
if err != nil {
fh.Close()
return err
}
if _, err := io.Copy(w, fh); err != nil {
fh.Close()
return err
}
fh.Close()
return nil
})
if walkErr != nil {
return walkErr
}
return zw.Close()
}
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
entries, err := os.ReadDir(absDir)
if err != nil {
return err
}
for _, e := range entries {
full := filepath.Join(absDir, e.Name())
if _, ok := normExclude[full]; ok {
continue
}
if err := os.RemoveAll(full); err != nil {
return err
}
}
return nil
}
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
func unzipToDir(zipPath, dstDir string) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
if err := os.MkdirAll(dstDir, 0755); err != nil {
return err
}
absDst, _ := filepath.Abs(dstDir)
for _, f := range zr.File {
// 构造目标路径并做路径遍历保护
cleanName := filepath.Clean(f.Name)
targetPath := filepath.Join(absDst, cleanName)
if !strings.HasPrefix(targetPath, absDst+string(os.PathSeparator)) && targetPath != absDst {
return fmt.Errorf("illegal file path in zip: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(targetPath)
if err != nil {
rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
rc.Close()
return err
}
out.Close()
rc.Close()
}
return nil
}
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
func mergeClientInfo(db *gorm.DB) {
var clientInfos []common.ClientInfo
@@ -212,14 +362,52 @@ func GetDBInstance() *gorm.DB {
once.Do(func() {
var err error
logConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
func() {
backupZipPath := filepath.Join(".", "data", "backup.zip")
if _, statErr := os.Stat(backupZipPath); statErr == nil {
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
if err := os.MkdirAll("./backup", 0755); err != nil {
log.Printf("[restore] failed to create backup dir: %v", err)
} else {
tsName := time.Now().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
log.Printf("[restore] failed to zip current data: %v", zipErr)
} else {
log.Printf("[restore] current data zipped to %s", bakPath)
}
}
if utils.VersionHash == "unknown" {
logConfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
// 5. 删除除了 ./data/backup.zip 之外的所有文件
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
}
// 6. 解压 ./data/backup.zip 到 ./data
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
} else {
log.Printf("[restore] backup.zip extracted to ./data")
}
// 7. 删除 ./data/backup.zip
if rmErr := os.Remove(backupZipPath); rmErr != nil {
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
} else {
log.Printf("[restore] backup.zip removed")
}
// 8. 删除标记
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
} else {
log.Printf("[restore] komari-backup-markup removed")
}
}
}()
logConfig := &gorm.Config{
Logger: logutil.NewGormLogger(),
}
// 根据数据库类型选择不同的连接方式
@@ -231,7 +419,12 @@ func GetDBInstance() *gorm.DB {
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
}
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
instance.Exec("PRAGMA wal = ON;")
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
}
instance.Exec("VACUUM;")
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
case "mysql":
// MySQL 连接
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
@@ -245,7 +438,6 @@ func GetDBInstance() *gorm.DB {
log.Fatalf("Failed to connect to MySQL database: %v", err)
}
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
default:
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
}
@@ -255,6 +447,7 @@ func GetDBInstance() *gorm.DB {
&models.User{},
&models.Client{},
&models.Record{},
&models.GPURecord{},
&models.Config{},
&models.Log{},
&models.Clipboard{},
@@ -264,6 +457,7 @@ func GetDBInstance() *gorm.DB {
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.ThemeConfiguration{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
@@ -274,6 +468,12 @@ func GetDBInstance() *gorm.DB {
if err != nil {
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
}
err = instance.Table("gpu_records_long_term").AutoMigrate(
&models.GPURecord{},
)
if err != nil {
log.Printf("Failed to create gpu_records_long_term table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
+21 -10
View File
@@ -1,15 +1,23 @@
package models
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
@@ -18,10 +26,13 @@ type Config struct {
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
@@ -0,0 +1,11 @@
package messageevent
const (
Offline = "Offline"
Online = "Online"
Expire = "Expire"
Renew = "Renew"
Login = "Login"
Alert = "Alert"
Traffic = "Traffic"
)
+10
View File
@@ -1,6 +1,16 @@
package models
import "time"
type MessageSenderProvider struct {
Name string `json:"name" gorm:"primaryKey;unique;not null"`
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
}
type EventMessage struct {
Event string `json:"event"`
Clients []Client `json:"clients"`
Time time.Time `json:"time"`
Message string `json:"message"`
Emoji string `json:"emoji"`
}
+45 -29
View File
@@ -8,35 +8,38 @@ import (
// Client represents a registered client device
type Client struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
Hidden bool `json:"hidden" gorm:"default:false"`
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// User represents an authenticated user
@@ -87,6 +90,19 @@ type Record struct {
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
//Uptime int64 `json:"uptime" gorm:"type:bigint"`
}
// GPURecord logs individual GPU metrics over time
type GPURecord struct {
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
Time LocalTime `json:"time" gorm:"index"` // 记录时间
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// StringArray represents a slice of strings stored as JSON in the database
+30 -7
View File
@@ -2,11 +2,34 @@ package models
// Theme represents a komari theme information
type Theme struct {
Name string `json:"name"` // 主题名称
Short string `json:"short"` // 短名称,用作文件夹名
Description string `json:"description"` // 主题描述
Version string `json:"version"` // 版本号
Author string `json:"author"` // 作者
URL string `json:"url"` // 主题URL
Preview string `json:"preview"` // 预览图片相对路径
Name string `json:"name"` // 主题名称
Short string `json:"short"` // 短名称,用作文件夹名
Description string `json:"description"` // 主题描述
Version string `json:"version"` // 版本号
Author string `json:"author"` // 作者
URL string `json:"url"` // 主题URL
Preview string `json:"preview"` // 预览图片相对路径
Configuration Configuration `json:"configuration"` // 声明配置项
}
type Configuration struct {
Type string `json:"type"` // managed
Icon string `json:"icon"` // 图标
Name string `json:"name"`
Data any `json:"data"` // 配置数据
}
type ManagedThemeConfigurationItem struct {
Key string `json:"key"`
Name string `json:"name"`
Required bool `json:"required"`
Type string `json:"type"` // string number select switch title
Options string `json:"options"`
Default any `json:"default"`
Help string `json:"help"`
}
type ThemeConfiguration struct {
Short string `json:"short" gorm:"primaryKey;unique;not null"`
Data string `json:"data" gorm:"type:longtext" default:"{}"`
}
+203
View File
@@ -18,14 +18,61 @@ func RecordOne(rec models.Record) error {
return db.Create(&rec).Error
}
func RecordGPU(rec models.GPURecord) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
}
func DeleteAll() error {
db := dbcore.GetDBInstance()
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
return err
}
if err := db.Exec("DELETE FROM gpu_records_long_term").Error; err != nil {
return err
}
if err := db.Exec("DELETE FROM gpu_records").Error; err != nil {
return err
}
return db.Exec("DELETE FROM records").Error
}
// GetGPURecordsByClientAndTime 获取GPU记录数据
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
db := dbcore.GetDBInstance()
var records []models.GPURecord
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recentRecords []models.GPURecord
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).
Order("time ASC, device_index ASC").Find(&recentRecords).Error
if err != nil {
log.Printf("Error fetching recent GPU records for client %s between %s and %s: %v", uuid, recentStart, end, err)
return nil, err
}
}
var longTermRecords []models.GPURecord
err := db.Table("gpu_records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).
Order("time ASC, device_index ASC").Find(&longTermRecords).Error
if err != nil {
log.Printf("Error fetching long-term GPU records for client %s between %s and %s: %v", uuid, start, end, err)
return recentRecords, nil
}
// 合并结果 - 不再需要类型转换
records = append(records, recentRecords...)
records = append(records, longTermRecords...)
return records, nil
}
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
db := dbcore.GetDBInstance()
err = db.Where("client = ?", uuid).Order("time DESC").Limit(1).Find(&Record).Error
@@ -35,6 +82,8 @@ func GetLatestRecord(uuid string) (Record []models.Record, err error) {
func DeleteRecordBefore(before time.Time) error {
db := dbcore.GetDBInstance()
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
db.Table("gpu_records_long_term").Where("time < ?", before).Delete(&models.GPURecord{})
db.Where("time < ?", before).Delete(&models.GPURecord{})
return db.Where("time < ?", before).Delete(&models.Record{}).Error
}
@@ -117,10 +166,17 @@ func CompactRecord() error {
return err
}
err = migrateGPURecords(db)
if err != nil {
log.Printf("Error migrating GPU records: %v", err)
return err
}
if flags.DatabaseType == "sqlite" {
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming database: %v", err)
}
db.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
}
//log.Printf("Record compaction completed")
return nil
@@ -159,6 +215,7 @@ func migrateOldRecords(db *gorm.DB) error {
Process []int
Connections []int
ConnectionsUdp []int
Uptime []int64
}
groupedRecords := make(map[string]*groupData)
@@ -185,6 +242,7 @@ func migrateOldRecords(db *gorm.DB) error {
data.Process = append(data.Process, record.Process)
data.Connections = append(data.Connections, record.Connections)
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
//data.Uptime = append(data.Uptime, record.Uptime)
}
getPercentile := func(values []float64, percentile float64) float64 {
@@ -279,6 +337,7 @@ func migrateOldRecords(db *gorm.DB) error {
Process: getInt32Percentile(data.Process, high_percentile),
Connections: getInt32Percentile(data.Connections, high_percentile),
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
//Uptime: getIntPercentile(data.Uptime, high_percentile),
}
// 如果记录已存在则更新,否则创建新记录
@@ -301,3 +360,147 @@ func migrateOldRecords(db *gorm.DB) error {
return nil
})
}
// migrateGPURecords 压缩GPU记录数据
func migrateGPURecords(db *gorm.DB) error {
fourHoursAgo := time.Now().Add(-4 * time.Hour)
// 查询超过4小时的GPU记录
var gpuRecords []models.GPURecord
if err := db.Where("time < ?", fourHoursAgo).Find(&gpuRecords).Error; err != nil {
return err
}
if len(gpuRecords) == 0 {
return nil
}
// 按Client + DeviceIndex + 15分钟时间窗口分组
type gpuGroupKey struct {
Client string
DeviceIndex int
TimeSlot time.Time
DeviceName string
}
type gpuGroupData struct {
MemTotal []int64
MemUsed []int64
Utilization []float32
Temperature []int
}
groupedGPUs := make(map[gpuGroupKey]*gpuGroupData)
for _, record := range gpuRecords {
key := gpuGroupKey{
Client: record.Client,
DeviceIndex: record.DeviceIndex,
TimeSlot: record.Time.ToTime().Truncate(15 * time.Minute),
DeviceName: record.DeviceName,
}
if _, ok := groupedGPUs[key]; !ok {
groupedGPUs[key] = &gpuGroupData{}
}
data := groupedGPUs[key]
data.MemTotal = append(data.MemTotal, record.MemTotal)
data.MemUsed = append(data.MemUsed, record.MemUsed)
data.Utilization = append(data.Utilization, record.Utilization)
data.Temperature = append(data.Temperature, record.Temperature)
}
// 百分位数计算函数 (复用传统Record压缩逻辑)
getPercentile := func(values []float64, percentile float64) float64 {
if len(values) == 0 {
return 0
}
sortedValues := make([]float64, len(values))
copy(sortedValues, values)
sort.Float64s(sortedValues)
index := float64(len(sortedValues)-1) * percentile
lowerIndex := int(index)
if lowerIndex >= len(sortedValues)-1 {
return sortedValues[len(sortedValues)-1]
}
frac := index - float64(lowerIndex)
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
}
getIntPercentile := func(values []int64, percentile float64) int64 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int64(getPercentile(floats, percentile))
}
// 温度数据转换辅助函数
convertIntToInt64 := func(values []int) []int64 {
result := make([]int64, len(values))
for i, v := range values {
result[i] = int64(v)
}
return result
}
getFloat32Percentile := func(values []float32, percentile float64) float32 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return float32(getPercentile(floats, percentile))
}
// 保持与传统Record压缩的一致性
high_percentile := 0.7
return db.Transaction(func(tx *gorm.DB) error {
for key, data := range groupedGPUs {
// 检查是否已存在记录
var existingCount int64
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
key.Client, key.DeviceIndex, key.TimeSlot).Count(&existingCount).Error; err != nil {
return err
}
compressedGPU := models.GPURecord{
Client: key.Client,
DeviceIndex: key.DeviceIndex,
Time: models.FromTime(key.TimeSlot),
DeviceName: key.DeviceName,
MemTotal: getIntPercentile(data.MemTotal, high_percentile),
MemUsed: getIntPercentile(data.MemUsed, high_percentile),
Utilization: getFloat32Percentile(data.Utilization, high_percentile),
Temperature: int(getIntPercentile(convertIntToInt64(data.Temperature), high_percentile)),
}
if existingCount > 0 {
// 更新已存在记录
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
key.Client, key.DeviceIndex, key.TimeSlot).Updates(&compressedGPU).Error; err != nil {
return err
}
} else {
// 创建新记录
if err := tx.Table("gpu_records_long_term").Create(&compressedGPU).Error; err != nil {
return err
}
}
}
// 删除已压缩的原始GPU数据
if err := tx.Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
return err
}
return nil
})
}
+10 -11
View File
@@ -61,15 +61,6 @@ func SavePingRecord(record models.PingRecord) error {
return db.Create(&record).Error
}
func GetPingRecords(client string) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ?", client).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func DeletePingRecordsBefore(time time.Time) error {
db := dbcore.GetDBInstance()
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
@@ -101,10 +92,18 @@ func ReloadPingSchedule() error {
}
return utils.ReloadPingSchedule(pingTasks)
}
func GetPingRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.PingRecord, error) {
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time DESC").Find(&records).Error; err != nil {
dbQuery := db.Model(&models.PingRecord{})
if uuid != "" {
dbQuery = dbQuery.Where("client = ?", uuid)
}
if taskId >= 0 {
dbQuery = dbQuery.Where("task_id = ?", uint(taskId))
}
if err := dbQuery.Where("time >= ? AND time <= ?", start, end).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
+1 -1
View File
@@ -83,7 +83,7 @@ func DeleteTaskByTaskId(taskId string) error {
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
}
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp models.LocalTime) error {
return dbcore.GetDBInstance().
Model(&models.TaskResult{}).
Where("task_id = ? AND client = ?", taskId, clientId).
+101
View File
@@ -0,0 +1,101 @@
package database
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetPublicInfo() (any, error) {
cst, err := config.Get()
if err != nil {
return nil, err
}
db := dbcore.GetDBInstance()
tc := models.ThemeConfiguration{}
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
if err != nil {
tc.Data = "{}"
}
tc_data := gin.H{}
err = json.Unmarshal([]byte(tc.Data), &tc_data)
if err != nil {
log.Printf("%v", err)
}
// Try to load theme declaration file and merge defaults for managed configuration
// Theme declarations live in ./data/theme/<short>/komari-theme.json
if cst.Theme != "" && cst.Theme != "default" {
themeConfigPath := filepath.Join("./data/theme", cst.Theme, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); err == nil {
b, err := os.ReadFile(themeConfigPath)
if err == nil {
var themeDecl struct {
Configuration struct {
Type string `json:"type"`
Data []models.ManagedThemeConfigurationItem `json:"data"`
} `json:"configuration"`
}
if err := json.Unmarshal(b, &themeDecl); err == nil {
if themeDecl.Configuration.Type == "managed" {
for _, item := range themeDecl.Configuration.Data {
if item.Key == "" {
continue
}
// missing
if _, exists := tc_data[item.Key]; !exists {
var def any = item.Default
// select
if item.Type == "select" {
if def == nil || def == "" {
if item.Options != "" {
opts := strings.Split(item.Options, ",")
if len(opts) > 0 {
def = strings.TrimSpace(opts[0])
}
}
}
}
// number->0, string->"", switch->false
if def == nil {
switch item.Type {
case "number":
def = 0
case "switch":
def = false
default:
def = ""
}
}
tc_data[item.Key] = def
}
}
}
}
}
}
}
return gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
"private_site": cst.PrivateSite,
"theme": cst.Theme,
"theme_settings": tc_data,
}, nil
}
+18 -6
View File
@@ -20,10 +20,6 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. ワンクリックインストールスクリプトを使用する
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
```bash
@@ -107,8 +103,24 @@ sudo ./install-komari.sh
IssueやPull Requestを歓迎します!
## 謝辞
- こんなに暇でいられる自分に感謝
- PRを提出し、テーマを作成してくれたすべての開発者
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
### 🚀 SharonNetworks スポンサー
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
### オープンソースコミュニティ
PR を送ってくれた方、テーマを作成してくれた全ての開発者
—— そして:こんなに暇でいられる自分に感謝
## Star履歴
+18 -7
View File
@@ -21,10 +21,6 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. 使用一鍵安裝腳本
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
```bash
@@ -108,9 +104,24 @@ sudo ./install-komari.sh
歡迎提交 Issue 或 Pull Request!
## 鳴謝
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感謝我自己能這麼閒
- 提交PR、製作主題的各位開發者
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 贊助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
亞太資料中心提供頂級的中國優化網路接入 · 低延遲 & 高頻寬 & 提供 Tbps 級本地清洗高防服務,為您的業務保駕護航,為您的客戶提供極致體驗。加入社群 [Telegram 群組](https://t.me/SharonNetwork) 可參與公益募捐或群內抽獎免費使用。
### 開源社群
提交 PR、製作主題的各位開發者
—— 以及:感謝我自己能這麼閒
## Star History
+19 -8
View File
@@ -6,7 +6,7 @@
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
@@ -21,10 +21,6 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
```bash
@@ -108,9 +104,24 @@ sudo ./install-komari.sh
欢迎提交 Issue 或 Pull Request!
## 鸣谢
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感谢我自己能这么闲
- 提交PR、制作主题的各位开发者
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 赞助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时 & 高带宽 & 提供 Tbps 级本地清洗高防服务,为您的业务保驾护航,为您的客户提供极致体验。加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用。
### 开源社区
提交 PR、制作主题的各位开发者
—— 以及:感谢我自己能这么闲
## Star History
+20 -8
View File
@@ -1,8 +1,13 @@
module github.com/komari-monitor/komari
go 1.23.2
go 1.24.0
toolchain go1.24.11
require (
github.com/coreos/go-oidc/v3 v3.15.0
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -11,6 +16,9 @@ require (
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/image v0.35.0
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
@@ -19,11 +27,11 @@ require (
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
// golang.org/x/crypto Vulnerable to Denial of Service (DoS) via Slow or Incomplete Key Exchange #3
require golang.org/x/crypto v0.37.0 // indirect
require golang.org/x/crypto v0.39.0 // indirect
// HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net #2
// golang.org/x/net vulnerable to Cross-site Scripting #4
require golang.org/x/net v0.39.0 // indirect
require golang.org/x/net v0.41.0 // indirect
require (
filippo.io/edwards25519 v1.1.0 // indirect
@@ -32,20 +40,23 @@ require (
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.27 // indirect
@@ -58,9 +69,10 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+56 -11
View File
@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
@@ -10,17 +12,31 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -29,14 +45,20 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -83,7 +105,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -107,17 +128,39 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -125,6 +168,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1 -1
View File
@@ -26,7 +26,7 @@ log_step() {
# Global variables
INSTALL_DIR="/opt/komari"
DATA_DIR="/opt/komari/data"
DATA_DIR="/opt/komari"
SERVICE_NAME="komari"
BINARY_PATH="$INSTALL_DIR/komari"
DEFAULT_PORT="25774"
+9
View File
@@ -2,12 +2,21 @@ package main
import (
"log"
"log/slog"
"github.com/komari-monitor/komari/cmd"
"github.com/komari-monitor/komari/utils"
logutil "github.com/komari-monitor/komari/utils/log"
)
func main() {
if utils.VersionHash == "unknown" {
logutil.SetupGlobalLogger(slog.LevelDebug)
} else {
logutil.SetupGlobalLogger(slog.LevelInfo)
}
log.Printf("Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
cmd.Execute()
}
+31
View File
@@ -0,0 +1,31 @@
package utils
import "github.com/gin-gonic/gin"
// https://github.com/labstack/echo/blob/98ca08e7dd64075b858e758d6693bf9799340756/context.go#L275-L294
func GetScheme(c *gin.Context) string {
// Can't use `r.Request.URL.Scheme`
// See: https://groups.google.com/forum/#!topic/golang-nuts/pMUkBlQBDF0
if c.Request.TLS != nil {
return "https"
}
if scheme := c.Request.Header.Get("X-Forwarded-Proto"); scheme != "" {
return scheme
}
if scheme := c.Request.Header.Get("X-Forwarded-Protocol"); scheme != "" {
return scheme
}
if ssl := c.Request.Header.Get("X-Forwarded-Ssl"); ssl == "on" {
return "https"
}
if scheme := c.Request.Header.Get("X-Url-Scheme"); scheme != "" {
return scheme
}
return "http"
}
func GetCallbackURL(c *gin.Context) string {
scheme := GetScheme(c)
host := c.Request.Host
return scheme + "://" + host + "/api/oauth_callback"
}
+17 -1
View File
@@ -17,13 +17,19 @@ func Parse(v any) []Item {
val = val.Elem()
}
var allowTypes = []string{"option", "richtext"}
var items []Item
for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i)
typ := field.Tag.Get("type")
if !contains(allowTypes, typ) {
typ = field.Type.Name()
}
item := Item{
Name: field.Tag.Get("json"),
Required: field.Tag.Get("required") == "true",
Type: field.Type.Name(),
Type: typ,
Options: field.Tag.Get("options"),
Default: field.Tag.Get("default"),
Help: field.Tag.Get("help"),
@@ -35,3 +41,13 @@ func Parse(v any) []Item {
}
return items
}
// contains reports whether s is present in slice.
func contains(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
+78
View File
@@ -0,0 +1,78 @@
package log
import (
"fmt"
"log/slog"
"time"
"github.com/gin-gonic/gin"
)
// GinLogger 返回一个 gin.HandlerFunc,用于记录 HTTP 请求
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
// 处理请求
c.Next()
// 计算延迟
latency := time.Since(start)
// 获取状态码
statusCode := c.Writer.Status()
statusCodeColored := func(code int) string {
if code >= 500 {
return Red("%d", code)
} else if code >= 400 {
return Yellow("%d", code)
} else if code >= 300 {
return Cyan("%d", code)
} else {
return Green("%d", code)
}
}(statusCode)
msg := fmt.Sprintf("%s %s %s", statusCodeColored, c.Request.Method, path)
if query != "" {
msg += "?" + query
}
msg += fmt.Sprintf(" | %s | %s", c.ClientIP(), latency)
if len(c.Errors) > 0 {
msg += " | " + c.Errors.String()
}
handler := slog.Default().Handler()
var level slog.Level
if statusCode >= 500 {
level = slog.LevelError
} else {
level = slog.LevelInfo
}
r := slog.NewRecord(time.Now(), level, msg, 0)
r.AddAttrs(slog.String("_group", "GIN")) // 添加分组标识
handler.Handle(c.Request.Context(), r)
}
}
// GinRecovery 返回一个 gin.HandlerFunc,用于恢复 panic
func GinRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
msg := fmt.Sprintf("panic recovered: %v | %s %s", err, c.Request.Method, c.Request.URL.Path)
handler := slog.Default().Handler()
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
r.AddAttrs(slog.String("_group", "GIN"))
handler.Handle(c.Request.Context(), r)
c.AbortWithStatus(500)
}
}()
c.Next()
}
}
+93
View File
@@ -0,0 +1,93 @@
package log
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
komari_utils "github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"gorm.io/gorm/utils"
)
// GormLogger 实现 gorm.io/gorm/logger.Interface
type GormLogger struct {
SlowThreshold time.Duration
IgnoreRecordNotFoundError bool
LogLevel gormlogger.LogLevel
}
// NewGormLogger 创建 GORM logger
func NewGormLogger() *GormLogger {
return &GormLogger{
SlowThreshold: 200 * time.Millisecond,
IgnoreRecordNotFoundError: true,
LogLevel: func(hash string) gormlogger.LogLevel {
if hash == "unknown" {
return gormlogger.Info
}
return gormlogger.Silent
}(komari_utils.VersionHash),
}
}
func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
newlogger := *l
newlogger.LogLevel = level
return &newlogger
}
func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Info {
slog.InfoContext(ctx, fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Warn {
slog.WarnContext(ctx, fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Error {
slog.ErrorContext(ctx, fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.LogLevel <= gormlogger.Silent {
return
}
elapsed := time.Since(begin)
sql, rows := fc()
fileWithLineNum := utils.FileWithLineNum()
handler := slog.Default().Handler()
switch {
case err != nil && l.LogLevel >= gormlogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | ERROR: %v %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, err, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormlogger.Warn:
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | SLOW QUERY %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now(), slog.LevelWarn, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
case l.LogLevel >= gormlogger.Info:
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now(), slog.LevelDebug, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
}
}
+180
View File
@@ -0,0 +1,180 @@
package log
import (
"context"
"fmt"
"io"
stdlog "log"
"log/slog"
"os"
"runtime"
"time"
)
func Green(format string, v ...interface{}) string {
return fmt.Sprintf("\033[32m"+format+"\033[0m", v...)
}
func Yellow(format string, v ...interface{}) string {
return fmt.Sprintf("\033[33m"+format+"\033[0m", v...)
}
func Red(format string, v ...interface{}) string {
return fmt.Sprintf("\033[31m"+format+"\033[0m", v...)
}
func Blue(format string, v ...interface{}) string {
return fmt.Sprintf("\033[34m"+format+"\033[0m", v...)
}
func Cyan(format string, v ...interface{}) string {
return fmt.Sprintf("\033[36m"+format+"\033[0m", v...)
}
func Gray(format string, v ...interface{}) string {
return fmt.Sprintf("\033[90m"+format+"\033[0m", v...)
}
func White(format string, v ...interface{}) string {
return fmt.Sprintf("\033[37m"+format+"\033[0m", v...)
}
type LogHandler struct {
w io.Writer
level slog.Level
group string
}
func NewHandler(w io.Writer, level slog.Level) *LogHandler {
return &LogHandler{w: w, level: level, group: ""}
}
func (h *LogHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *LogHandler) Handle(_ context.Context, r slog.Record) error {
var file string
var line int
if r.PC != 0 {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
file = f.File
line = f.Line
}
timeStr := r.Time.Format("2006/01/02 15:04:05")
// 检查是否有分组信息(从 attributes 中获取)
group := h.group
r.Attrs(func(a slog.Attr) bool {
if a.Key == "_group" {
group = a.Value.String()
return false // 停止迭代
}
return true
})
levelStr := ""
switch r.Level {
case slog.LevelDebug:
if group != "" {
levelStr = Cyan("[DEBUG/%s]", group)
} else {
levelStr = Cyan("[DEBUG]")
}
case slog.LevelInfo:
if group != "" {
levelStr = Green(fmt.Sprintf("[INFO/%s]", group))
} else {
levelStr = Green("[INFO]")
}
case slog.LevelWarn:
if group != "" {
levelStr = Yellow(fmt.Sprintf("[WARN/%s]", group))
} else {
levelStr = Yellow("[WARN]")
}
case slog.LevelError:
if group != "" {
levelStr = Red(fmt.Sprintf("[ERROR/%s]", group))
} else {
levelStr = Red("[ERROR]")
}
}
var msg string
if file != "" {
msg = fmt.Sprintf("%s %s %s %s",
timeStr,
levelStr,
r.Message,
Gray(fmt.Sprintf("(%s:%d)", file, line)))
} else {
msg = fmt.Sprintf("%s %s %s",
timeStr,
levelStr,
r.Message)
}
r.Attrs(func(a slog.Attr) bool {
if a.Key != "_group" { // 跳过内部使用的 _group 属性
msg += fmt.Sprintf(" %s=%s", Cyan(a.Key), Yellow(fmt.Sprintf("%v", a.Value)))
}
return true
})
msg += "\n"
_, err := h.w.Write([]byte(msg))
return err
}
func (h *LogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h
}
func (h *LogHandler) WithGroup(name string) slog.Handler {
return h
}
// SetupGlobalLogger 设置全局标准库 log 使用 slog handler
func SetupGlobalLogger(level slog.Level) {
handler := NewHandler(os.Stdout, level)
logger := slog.New(handler)
// 设置 slog 默认 logger
slog.SetDefault(logger)
// 设置标准库 log 使用 slog
stdlog.SetOutput(os.Stdout)
stdlog.SetFlags(0) // 清除默认标志
stdlog.SetPrefix("")
// 替换标准库 log 的输出为 slog handler
stdlog.SetOutput(&writerAdapter{handler: handler, level: level})
}
// writerAdapter 将标准库 log 的输出适配到 slog
type writerAdapter struct {
handler slog.Handler
level slog.Level
}
func (w *writerAdapter) Write(p []byte) (n int, err error) {
msg := string(p)
if len(msg) > 0 && msg[len(msg)-1] == '\n' {
msg = msg[:len(msg)-1]
}
var pcs [1]uintptr
runtime.Callers(4, pcs[:]) // skip [Callers, Write, log.Output, log.Printf/etc]
r := slog.NewRecord(time.Now(), w.level, msg, pcs[0])
return len(p), w.handler.Handle(context.Background(), r)
}
// GetWriter 返回一个 io.Writer,可以用于 Gin 等框架
func GetWriter() io.Writer {
handler := slog.Default().Handler()
return &writerAdapter{handler: handler, level: slog.LevelInfo}
}
+5
View File
@@ -1,9 +1,14 @@
package messageSender
import (
_ "github.com/komari-monitor/komari/utils/messageSender/bark"
_ "github.com/komari-monitor/komari/utils/messageSender/email"
_ "github.com/komari-monitor/komari/utils/messageSender/empty"
_ "github.com/komari-monitor/komari/utils/messageSender/serverchan3"
_ "github.com/komari-monitor/komari/utils/messageSender/serverchanturbo"
_ "github.com/komari-monitor/komari/utils/messageSender/javascript"
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
)
func All() {
+118
View File
@@ -0,0 +1,118 @@
package bark
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type BarkSender struct {
Addition
}
func (b *BarkSender) GetName() string {
return "bark"
}
func (b *BarkSender) GetConfiguration() factory.Configuration {
return &b.Addition
}
func (b *BarkSender) Init() error {
// 验证 ServerURL 格式
if b.Addition.ServerURL != "" {
if _, err := url.Parse(b.Addition.ServerURL); err != nil {
return fmt.Errorf("invalid server URL: %v", err)
}
}
return nil
}
func (b *BarkSender) Destroy() error {
return nil
}
func (b *BarkSender) SendTextMessage(message, title string) error {
if b.Addition.DeviceKey == "" {
return fmt.Errorf("device key is required")
}
if message == "" {
return fmt.Errorf("message is empty")
}
// 准备请求数据
payload := map[string]interface{}{
"body": message,
"device_key": b.Addition.DeviceKey,
}
// 如果有标题,添加标题
if title != "" {
payload["title"] = title
}
// 添加可选参数
if b.Addition.Icon != "" {
payload["icon"] = b.Addition.Icon
}
if b.Addition.Level != "" {
payload["level"] = b.Addition.Level
}
// 序列化 JSON
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %v", err)
}
// 构建请求 URL
serverURL := b.Addition.ServerURL
if serverURL == "" {
serverURL = "https://api.day.app"
}
// 确保 URL 以正确的格式结尾
serverURL = strings.TrimRight(serverURL, "/")
requestURL := fmt.Sprintf("%s/push", serverURL)
// 发送 HTTP POST 请求
resp, err := http.Post(requestURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bark API returned non-OK status: %d", resp.StatusCode)
}
// 解析响应
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// 如果解析失败,认为发送成功(某些 Bark 服务器可能返回纯文本)
return nil
}
// 检查 Bark API 响应
if result.Code != 200 {
return fmt.Errorf("bark API error (code: %d): %s", result.Code, result.Message)
}
return nil
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*BarkSender)(nil)
+18
View File
@@ -0,0 +1,18 @@
package bark
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
ServerURL string `json:"server_url" required:"true" default:"https://api.day.app" help:"Bark server URL, e.g., https://api.day.app or your self-hosted server address"`
DeviceKey string `json:"device_key" required:"true" help:"Your Bark device key, which can be found in the Bark App"`
Icon string `json:"icon" help:"Push notification icon, supports URL or system icon name"`
Level string `json:"level" type:"option" default:"timeSensitive" options:"active,timeSensitive,passive,critical" help:"Push notification level: active, timeSensitive (default), passive, critical"`
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &BarkSender{}
})
}
+222 -48
View File
@@ -1,10 +1,16 @@
package email
import (
"bytes"
"crypto/tls"
"fmt"
"mime"
"mime/quotedprintable"
"net/mail"
"net/smtp"
"strconv"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
@@ -13,6 +19,59 @@ type EmailSender struct {
Addition
}
// loginAuth 是一个更宽松的 SMTP 认证实现,支持更多 SMTP 服务器
// 它不会严格验证服务器主机名,从而兼容微软邮箱、网易邮箱等服务
type loginAuth struct {
username string
password string
host string
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:", "User Name":
return []byte(a.username), nil
case "Password:", "password:":
return []byte(a.password), nil
default:
// 某些服务器可能发送 base64 编码的提示
// 尝试返回用户名或密码
prompt := strings.ToLower(strings.TrimSpace(string(fromServer)))
if strings.Contains(prompt, "user") {
return []byte(a.username), nil
}
return []byte(a.password), nil
}
}
return nil, nil
}
// plainAuthWithoutCheck 是一个不检查主机名的 PlainAuth 实现
// 用于解决某些 SMTP 服务器主机名与配置不匹配的问题
type plainAuthWithoutCheck struct {
identity string
username string
password string
host string
}
func (a *plainAuthWithoutCheck) Start(server *smtp.ServerInfo) (string, []byte, error) {
resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
return "PLAIN", resp, nil
}
func (a *plainAuthWithoutCheck) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
return nil, fmt.Errorf("unexpected server challenge")
}
return nil, nil
}
func (e *EmailSender) GetName() string {
return "email"
}
@@ -35,68 +94,183 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
return fmt.Errorf("email sending is not fully configured")
}
auth := smtp.PlainAuth(
"",
e.Addition.Username,
e.Addition.Password,
e.Addition.Host,
)
// 使用更宽松的认证方式,优先尝试 PLAIN,如果失败则尝试 LOGIN
// 这样可以兼容更多的 SMTP 服务器,包括微软邮箱、网易邮箱等
var auth smtp.Auth
if e.Addition.UseLoginAuth {
// 使用 LOGIN 认证(适用于某些旧的或特殊的 SMTP 服务器)
auth = &loginAuth{
username: e.Addition.Username,
password: e.Addition.Password,
host: e.Addition.Host,
}
} else {
// 使用不检查主机名的 PLAIN 认证(适用于大多数现代 SMTP 服务器)
auth = &plainAuthWithoutCheck{
identity: "",
username: e.Addition.Username,
password: e.Addition.Password,
host: e.Addition.Host,
}
}
msg := []byte("To: " + e.Addition.Receiver + "\r\n" +
"From: " + e.Addition.Sender + "\r\n" +
"Subject: " + title + "\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" +
message)
// Parse sender address (for MAIL FROM and header)
var senderAddr string
var senderHeader string
if addr, err := mail.ParseAddress(e.Addition.Sender); err == nil {
senderAddr = addr.Address
senderHeader = addr.String()
} else {
// Fallback: use raw string
senderAddr = e.Addition.Sender
senderHeader = e.Addition.Sender
}
// Parse recipients (support comma-separated list)
var rcptList []string
var rcptHeaderParts []string
if addrs, err := mail.ParseAddressList(e.Addition.Receiver); err == nil {
for _, a := range addrs {
rcptList = append(rcptList, a.Address)
rcptHeaderParts = append(rcptHeaderParts, a.String())
}
} else {
// Fallback simple split
parts := strings.Split(e.Addition.Receiver, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if a, err := mail.ParseAddress(p); err == nil {
rcptList = append(rcptList, a.Address)
rcptHeaderParts = append(rcptHeaderParts, a.String())
} else {
rcptList = append(rcptList, p)
rcptHeaderParts = append(rcptHeaderParts, p)
}
}
}
if len(rcptList) == 0 {
return fmt.Errorf("no valid recipient address parsed")
}
// RFC 2047 encode subject if non-ASCII
encodedSubject := mime.QEncoding.Encode("UTF-8", title)
// Encode body as quoted-printable to be safe with UTF-8 on servers lacking 8BITMIME
var bodyBuf bytes.Buffer
qp := quotedprintable.NewWriter(&bodyBuf)
if _, err := qp.Write([]byte(message)); err != nil {
return fmt.Errorf("failed to encode body: %w", err)
}
if err := qp.Close(); err != nil {
return fmt.Errorf("failed to finalize body encoding: %w", err)
}
// Compose headers
headers := []string{
"To: " + strings.Join(rcptHeaderParts, ", "),
"From: " + senderHeader,
"Subject: " + encodedSubject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: quoted-printable",
"Date: " + time.Now().Format(time.RFC1123Z),
fmt.Sprintf("Message-ID: <%d@%s>", time.Now().UnixNano(), e.Addition.Host),
}
fullMsg := []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyBuf.String())
addr := e.Addition.Host + ":" + strconv.Itoa(e.Addition.Port)
if e.Addition.UseSSL {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
// Use TLS. If port is 465, prefer implicit TLS. Otherwise, use STARTTLS.
if e.Addition.Port == 465 {
// Implicit TLS (SMTPS)
tlsCfg := &tls.Config{ServerName: e.Addition.Host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("failed to establish implicit TLS connection: %w", err)
}
defer conn.Close()
if err = c.StartTLS(&tls.Config{ServerName: e.Addition.Host}); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
c, err := smtp.NewClient(conn, e.Addition.Host)
if err != nil {
return fmt.Errorf("failed to create SMTP client over TLS: %w", err)
}
defer c.Close()
// Authenticate
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
// Set the sender and recipient
if err = c.Mail(e.Addition.Sender); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err = c.Rcpt(e.Addition.Receiver); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
if err = c.Mail(senderAddr); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, rcpt := range rcptList {
if err = c.Rcpt(rcpt); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", rcpt, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg)
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
if _, err = w.Write(fullMsg); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return c.Quit()
} else {
// STARTTLS
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
return c.Quit()
if err = c.StartTLS(&tls.Config{ServerName: e.Addition.Host}); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Mail(senderAddr); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, rcpt := range rcptList {
if err = c.Rcpt(rcpt); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", rcpt, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
if _, err = w.Write(fullMsg); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return c.Quit()
}
} else {
// Send without SSL/TLS (less secure)
// Send without SSL/TLS (less secure). We still reuse the composed message and parsed addresses.
return smtp.SendMail(
addr,
auth,
e.Addition.Sender,
[]string{e.Addition.Receiver},
msg,
senderAddr,
rcptList,
fullMsg,
)
}
}
+8 -7
View File
@@ -5,13 +5,14 @@ import (
)
type Addition struct {
Host string `json:"host" required:"true"`
Port int `json:"port" required:"true" default:"587"`
Username string `json:"username"`
Password string `json:"password"`
Sender string `json:"sender"`
Receiver string `json:"receiver"`
UseSSL bool `json:"use_ssl" default:"false"`
Host string `json:"host" required:"true"`
Port int `json:"port" required:"true" default:"587"`
Username string `json:"username"`
Password string `json:"password"`
Sender string `json:"sender"`
Receiver string `json:"receiver"`
UseSSL bool `json:"use_ssl" default:"false"`
UseLoginAuth bool `json:"use_login_auth" default:"false" help:"Use LOGIN authentication method instead of PLAIN. Enable this if you encounter authentication errors with Microsoft (Outlook/Office365), NetEase (163.com), or other email providers"`
}
func init() {
+7
View File
@@ -1,5 +1,7 @@
package factory
import "github.com/komari-monitor/komari/database/models"
type IMessageSender interface {
GetName() string
// 请务必返回 &Configuration{} 的指针
@@ -9,6 +11,11 @@ type IMessageSender interface {
Destroy() error
}
// IEventMessageSender 是可选接口,如果实现则可以接收结构化的事件消息
type IEventMessageSender interface {
SendEvent(event models.EventMessage) error
}
type Configuration interface{}
type MessageSenderConstructor func() IMessageSender
+301
View File
@@ -0,0 +1,301 @@
package javascript
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/dop251/goja"
)
// createFetchFunction 创建一个 fetch API 实现
func (j *JavaScriptSender) createFetchFunction() func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
panic(j.vm.NewTypeError("fetch requires at least 1 argument"))
}
url := call.Argument(0).String()
// 解析选项
options := map[string]interface{}{
"method": "GET",
"headers": make(map[string]string),
"body": "",
}
if len(call.Arguments) > 1 {
optObj := call.Argument(1).ToObject(j.vm)
if optObj != nil {
if method := optObj.Get("method"); method != nil && !goja.IsUndefined(method) {
options["method"] = method.String()
}
if headers := optObj.Get("headers"); headers != nil && !goja.IsUndefined(headers) {
headersObj := headers.ToObject(j.vm)
if headersObj != nil {
headerMap := make(map[string]string)
for _, key := range headersObj.Keys() {
headerMap[key] = headersObj.Get(key).String()
}
options["headers"] = headerMap
}
}
if body := optObj.Get("body"); body != nil && !goja.IsUndefined(body) {
options["body"] = body.String()
}
}
}
// 创建 Promise
promise, resolve, reject := j.vm.NewPromise()
go func() {
defer func() {
if r := recover(); r != nil {
reject(j.vm.ToValue(fmt.Sprintf("fetch panic: %v", r)))
}
}()
// 创建 HTTP 请求
method := options["method"].(string)
var body io.Reader
if options["body"].(string) != "" {
body = strings.NewReader(options["body"].(string))
}
req, err := http.NewRequest(method, url, body)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Failed to create request: %v", err)))
return
}
// 设置请求头
headers := options["headers"].(map[string]string)
for key, value := range headers {
req.Header.Set(key, value)
}
// 发送请求
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Fetch failed: %v", err)))
return
}
defer resp.Body.Close()
// 读取响应体
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Failed to read response: %v", err)))
return
}
// 创建响应对象
responseObj := j.vm.NewObject()
responseObj.Set("status", resp.StatusCode)
responseObj.Set("statusText", resp.Status)
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
// 响应头
headersObj := j.vm.NewObject()
for key, values := range resp.Header {
if len(values) > 0 {
headersObj.Set(key, values[0])
}
}
responseObj.Set("headers", headersObj)
// text() 方法
responseObj.Set("text", func(goja.FunctionCall) goja.Value {
textPromise, textResolve, _ := j.vm.NewPromise()
textResolve(j.vm.ToValue(string(bodyBytes)))
return j.vm.ToValue(textPromise)
})
// json() 方法
responseObj.Set("json", func(goja.FunctionCall) goja.Value {
jsonPromise, jsonResolve, jsonReject := j.vm.NewPromise()
var result interface{}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
jsonReject(j.vm.ToValue(fmt.Sprintf("Failed to parse JSON: %v", err)))
} else {
jsonResolve(j.vm.ToValue(result))
}
return j.vm.ToValue(jsonPromise)
})
resolve(responseObj)
}()
return j.vm.ToValue(promise)
}
}
// createXHRConstructor 创建一个 XMLHttpRequest 构造函数
func (j *JavaScriptSender) createXHRConstructor() func(goja.ConstructorCall) *goja.Object {
return func(call goja.ConstructorCall) *goja.Object {
xhr := call.This
// 内部状态
var method, url string
var headers = make(map[string]string)
var requestBody string
var async = true
// readyState
xhr.Set("readyState", 0)
xhr.Set("status", 0)
xhr.Set("statusText", "")
xhr.Set("responseText", "")
xhr.Set("response", "")
// 事件处理器
xhr.Set("onreadystatechange", goja.Null())
xhr.Set("onload", goja.Null())
xhr.Set("onerror", goja.Null())
// open 方法
xhr.Set("open", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
panic(j.vm.NewTypeError("open requires at least 2 arguments"))
}
method = call.Argument(0).String()
url = call.Argument(1).String()
if len(call.Arguments) > 2 {
async = call.Argument(2).ToBoolean()
}
xhr.Set("readyState", 1)
j.callHandler(xhr, "onreadystatechange")
return goja.Undefined()
})
// setRequestHeader 方法
xhr.Set("setRequestHeader", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
panic(j.vm.NewTypeError("setRequestHeader requires 2 arguments"))
}
key := call.Argument(0).String()
value := call.Argument(1).String()
headers[key] = value
return goja.Undefined()
})
// send 方法
xhr.Set("send", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Argument(0)) && !goja.IsNull(call.Argument(0)) {
requestBody = call.Argument(0).String()
}
sendFunc := func() {
defer func() {
if r := recover(); r != nil {
xhr.Set("readyState", 4)
xhr.Set("status", 0)
xhr.Set("statusText", fmt.Sprintf("Error: %v", r))
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
}
}()
// 创建请求
var body io.Reader
if requestBody != "" {
body = bytes.NewReader([]byte(requestBody))
}
req, err := http.NewRequest(method, url, body)
if err != nil {
xhr.Set("readyState", 4)
xhr.Set("status", 0)
xhr.Set("statusText", err.Error())
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
return
}
// 设置请求头
for key, value := range headers {
req.Header.Set(key, value)
}
// 发送请求
xhr.Set("readyState", 2)
j.callHandler(xhr, "onreadystatechange")
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
xhr.Set("readyState", 4)
xhr.Set("status", 0)
xhr.Set("statusText", err.Error())
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
return
}
defer resp.Body.Close()
// 读取响应
xhr.Set("readyState", 3)
j.callHandler(xhr, "onreadystatechange")
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
xhr.Set("readyState", 4)
xhr.Set("status", resp.StatusCode)
xhr.Set("statusText", err.Error())
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
return
}
// 完成
xhr.Set("readyState", 4)
xhr.Set("status", resp.StatusCode)
xhr.Set("statusText", resp.Status)
xhr.Set("responseText", string(bodyBytes))
xhr.Set("response", string(bodyBytes))
j.callHandler(xhr, "onreadystatechange")
j.callHandler(xhr, "onload")
}
if async {
go sendFunc()
} else {
sendFunc()
}
return goja.Undefined()
})
// getAllResponseHeaders 方法
xhr.Set("getAllResponseHeaders", func(call goja.FunctionCall) goja.Value {
return j.vm.ToValue("")
})
// getResponseHeader 方法
xhr.Set("getResponseHeader", func(call goja.FunctionCall) goja.Value {
return goja.Null()
})
return nil
}
}
// callHandler 调用事件处理器
func (j *JavaScriptSender) callHandler(obj *goja.Object, handlerName string) {
handler := obj.Get(handlerName)
if handler != nil && !goja.IsUndefined(handler) && !goja.IsNull(handler) {
if fn, ok := goja.AssertFunction(handler); ok {
fn(obj)
}
}
}
@@ -0,0 +1,361 @@
package javascript
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/require"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type JavaScriptSender struct {
Addition
vm *goja.Runtime
noopProgram *goja.Program
}
func (j *JavaScriptSender) GetName() string {
return "Javascript"
}
func (j *JavaScriptSender) GetConfiguration() factory.Configuration {
return &j.Addition
}
func (j *JavaScriptSender) Init() error {
if j.Addition.Script == "" {
return errors.New("JavaScript script is empty")
}
// 创建 JavaScript 运行时
j.vm = goja.New()
// 预编译一个 no-op 程序,用于驱动微任务队列
prog, errc := goja.Compile("noop.js", "void 0", false)
if errc == nil {
j.noopProgram = prog
}
// 设置 require 支持
new(require.Registry).Enable(j.vm)
// 注入全局对象和函数
j.setupGlobals()
// 加载用户脚本
_, err := j.vm.RunString(j.Addition.Script)
if err != nil {
return fmt.Errorf("failed to load JavaScript script: %v", err)
}
// 验证 sendMessage 函数是否存在
sendMessage := j.vm.Get("sendMessage")
if sendMessage == nil || goja.IsUndefined(sendMessage) {
return errors.New("sendMessage function not defined in script")
}
// 验证是否可调用
if _, ok := goja.AssertFunction(sendMessage); !ok {
return errors.New("sendMessage is not a function")
}
// sendEvent 函数是可选的,不强制要求存在
return nil
}
func (j *JavaScriptSender) Destroy() error {
if j.vm != nil {
j.vm = nil
}
return nil
}
func (j *JavaScriptSender) SendTextMessage(message, title string) error {
if j.vm == nil {
if err := j.Init(); err != nil {
return err
}
}
// 获取 sendMessage 函数
sendMessageFunc, ok := goja.AssertFunction(j.vm.Get("sendMessage"))
if !ok {
return errors.New("sendMessage is not a callable function")
}
// 调用 sendMessage 函数
resultChan := make(chan error, 1)
timeoutChan := time.After(30 * time.Second)
go func() {
defer func() {
if r := recover(); r != nil {
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
}
}()
result, err := sendMessageFunc(goja.Undefined(), j.vm.ToValue(message), j.vm.ToValue(title))
if err != nil {
resultChan <- fmt.Errorf("JavaScript error: %v", err)
return
}
// 处理 Promise 返回值
if promise, ok := result.Export().(*goja.Promise); ok {
// 等待 Promise 完成
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeoutChan:
resultChan <- errors.New("JavaScript execution timeout")
return
case <-ticker.C:
// 运行微任务以处理 Promise 回调
j.runMicrotasks()
state := promise.State()
if state == goja.PromiseStateFulfilled {
// Promise 成功完成,检查返回值
promiseResult := promise.Result()
if !promiseResult.ToBoolean() {
resultChan <- errors.New("sendMessage returned false")
} else {
resultChan <- nil
}
return
} else if state == goja.PromiseStateRejected {
// Promise 被拒绝
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
return
}
// state == goja.PromiseStatePending, 继续等待
}
}
} else {
// 处理布尔或其他返回值
if result.ToBoolean() {
resultChan <- nil
} else {
resultChan <- errors.New("sendMessage returned false")
}
}
}()
select {
case err := <-resultChan:
return err
case <-timeoutChan:
return errors.New("JavaScript execution timeout after 30 seconds")
}
}
func (j *JavaScriptSender) SendEvent(event models.EventMessage) error {
if j.vm == nil {
if err := j.Init(); err != nil {
return err
}
}
// 检查是否定义了 sendEvent 函数
sendEventValue := j.vm.Get("sendEvent")
if sendEventValue == nil || goja.IsUndefined(sendEventValue) {
// 如果没有定义 sendEvent,则回退到使用 SendTextMessage
return j.fallbackToTextMessage(event)
}
sendEventFunc, ok := goja.AssertFunction(sendEventValue)
if !ok {
// 如果 sendEvent 不是函数,回退到 SendTextMessage
return j.fallbackToTextMessage(event)
}
// 将 EventMessage 转换为 JavaScript 对象
eventJSON, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %v", err)
}
var eventMap map[string]interface{}
if err := json.Unmarshal(eventJSON, &eventMap); err != nil {
return fmt.Errorf("failed to unmarshal event: %v", err)
}
// 调用 sendEvent 函数
resultChan := make(chan error, 1)
timeoutChan := time.After(30 * time.Second)
go func() {
defer func() {
if r := recover(); r != nil {
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
}
}()
result, err := sendEventFunc(goja.Undefined(), j.vm.ToValue(eventMap))
if err != nil {
resultChan <- fmt.Errorf("JavaScript error: %v", err)
return
}
// 处理 Promise 返回值
if promise, ok := result.Export().(*goja.Promise); ok {
// 等待 Promise 完成
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeoutChan:
resultChan <- errors.New("JavaScript execution timeout")
return
case <-ticker.C:
// 运行微任务以处理 Promise 回调
j.runMicrotasks()
state := promise.State()
if state == goja.PromiseStateFulfilled {
// Promise 成功完成,检查返回值
promiseResult := promise.Result()
if !promiseResult.ToBoolean() {
resultChan <- errors.New("sendEvent returned false")
} else {
resultChan <- nil
}
return
} else if state == goja.PromiseStateRejected {
// Promise 被拒绝
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
return
}
// state == goja.PromiseStatePending, 继续等待
}
}
} else {
// 处理布尔或其他返回值
if result.ToBoolean() {
resultChan <- nil
} else {
resultChan <- errors.New("sendEvent returned false")
}
}
}()
select {
case err := <-resultChan:
return err
case <-timeoutChan:
return errors.New("JavaScript execution timeout after 30 seconds")
}
}
// fallbackToTextMessage 当没有定义 sendEvent 时,回退到使用文本消息格式
func (j *JavaScriptSender) fallbackToTextMessage(event models.EventMessage) error {
// 构建简单的文本消息
message := fmt.Sprintf("%s%s%s\nEvent: %s\nMessage: %s\nTime: %s",
event.Emoji, event.Emoji, event.Emoji,
event.Event,
event.Message,
event.Time.Format(time.RFC3339))
// 添加客户端信息
if len(event.Clients) > 0 {
clientNames := make([]string, 0, len(event.Clients))
for _, c := range event.Clients {
name := c.Name
if name == "" {
name = c.UUID
}
clientNames = append(clientNames, name)
}
message = fmt.Sprintf("%s%s%s\nEvent: %s\nClients: %s\nMessage: %s\nTime: %s",
event.Emoji, event.Emoji, event.Emoji,
event.Event,
clientNames,
event.Message,
event.Time.Format(time.RFC3339))
}
return j.SendTextMessage(message, event.Event)
}
// runMicrotasks 安全地推动 goja 的微任务队列(例如 Promise 回调)
func (j *JavaScriptSender) runMicrotasks() {
if j.vm == nil {
return
}
if j.noopProgram != nil {
_, _ = j.vm.RunProgram(j.noopProgram)
return
}
// 兜底: 直接运行一段 no-op 代码
_, _ = j.vm.RunString("void 0")
}
func (j *JavaScriptSender) setupGlobals() {
// 注入 console.log
console := j.vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
var args []interface{}
for _, arg := range call.Arguments {
args = append(args, arg.Export())
}
fmt.Println(args...)
return goja.Undefined()
})
console.Set("error", func(call goja.FunctionCall) goja.Value {
fmt.Print("Error: ")
for i, arg := range call.Arguments {
if i > 0 {
fmt.Print(" ")
}
fmt.Print(arg.Export())
}
fmt.Println()
return goja.Undefined()
})
j.vm.Set("console", console)
// 注入 fetch API
j.vm.Set("fetch", j.createFetchFunction())
// 注入 XMLHttpRequest (xhr)
j.vm.Set("XMLHttpRequest", j.createXHRConstructor())
// 注入 setTimeout
j.vm.Set("setTimeout", func(call goja.FunctionCall) goja.Value {
callback := call.Argument(0)
delay := call.Argument(1).ToInteger()
go func() {
time.Sleep(time.Duration(delay) * time.Millisecond)
if fn, ok := goja.AssertFunction(callback); ok {
fn(goja.Undefined())
}
}()
return goja.Undefined()
})
// 注入 Promise 构造函数
j.vm.RunString(`
if (typeof Promise === 'undefined') {
// Promise polyfill 会由 goja 自动提供
}
`)
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &JavaScriptSender{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*JavaScriptSender)(nil)
+5
View File
@@ -0,0 +1,5 @@
package javascript
type Addition struct {
Script string `json:"script" required:"true" type:"richtext" help:"JavaScript code (ES6 supported) that implements sendMessage(message, title) and optionally sendEvent(event) functions. Both should return a Promise or boolean. Available APIs: fetch(), xhr(), console.log()."`
}
+73
View File
@@ -4,7 +4,9 @@ import (
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/auditlog"
@@ -89,3 +91,74 @@ func SendTextMessage(message string, title string) error {
auditlog.Log("", "", "Failed to send message after 3 attempts: "+err.Error()+","+title, "error")
return err
}
func SendEvent(event models.EventMessage) error {
if CurrentProvider() == nil {
return fmt.Errorf("message sender provider is not initialized")
}
var err error
cfg, err := config.Get()
if err != nil {
return err
}
if !cfg.NotificationEnabled {
return nil
}
// 检查提供者是否实现了 IEventMessageSender 接口
if eventSender, ok := CurrentProvider().(factory.IEventMessageSender); ok {
// 如果实现了,直接调用 SendEvent
for i := 0; i < 3; i++ {
err = eventSender.SendEvent(event)
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" {
auditlog.Log("", "", "Event message sent: "+event.Event, "info")
return nil
}
}
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+event.Event, "error")
return err
}
// 如果没有实现,使用模板格式化为文本消息
messageTemplate := cfg.NotificationTemplate
if messageTemplate == "" {
messageTemplate = "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"
}
messageTemplate = parseTemplate(messageTemplate, event)
for i := 0; i < 3; i++ {
err = CurrentProvider().SendTextMessage(messageTemplate, event.Event)
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" { // QQ 会返回这个错误,但实际上消息是发送成功的
auditlog.Log("", "", "Event message sent: "+event.Event, "info")
return nil
}
}
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+event.Event, "error")
return err
}
func parseTemplate(messageTemplate string, event models.EventMessage) string {
// Aggregate client names. If Name is empty, fall back to UUID.
clientNames := make([]string, 0, len(event.Clients))
for _, c := range event.Clients {
name := c.Name
if strings.TrimSpace(name) == "" {
// fallback to UUID when name is not set
name = c.UUID
}
clientNames = append(clientNames, name)
}
joinedClients := strings.Join(clientNames, ", ")
replaceMap := map[string]string{
"{{event}}": event.Event,
"{{client}}": joinedClients,
"{{time}}": event.Time.Format(time.RFC3339),
"{{message}}": event.Message,
"{{emoji}}": event.Emoji,
}
result := messageTemplate
for placeholder, value := range replaceMap {
result = strings.ReplaceAll(result, placeholder, value)
}
return result
}
+21
View File
@@ -0,0 +1,21 @@
package serverchan3
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// Addition 为 Server酱³ 推送通道的配置项
// 所有字段通过管理页面以 JSON 形式进行设置
type Addition struct {
// APIURL 为接口完整地址,例如:https://<uid>.push.ft07.com/send/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://<uid>.push.ft07.com/send/<sendkey>.send;参考:https://sc3.ft07.com/"`
// Tags 为可选标签,使用 | 分割,例如:tag1|tag2|tag3
Tags string `json:"tags" help:"可选标签,使用 | 分割,例如 tag1|tag2|tag3"`
}
// 注册 Server酱³ 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChan3Sender{}
})
}
@@ -0,0 +1,105 @@
package serverchan3
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// ServerChan3Sender 为 Server酱³ 推送实现
type ServerChan3Sender struct {
Addition
}
// GetName 返回推送通道名称
func (s *ServerChan3Sender) GetName() string {
return "Server酱³"
}
// GetConfiguration 返回配置结构体指针
func (s *ServerChan3Sender) GetConfiguration() factory.Configuration {
return &s.Addition
}
// Init 初始化(当前无需处理)
func (s *ServerChan3Sender) Init() error { return nil }
// Destroy 清理(当前无需处理)
func (s *ServerChan3Sender) Destroy() error { return nil }
// SendTextMessage 发送文本消息(携带标题)。
// 简化为固定 JSON POST:内部仅组装 {"title": ..., "desp": ...},不允许用户自定义请求体或内容类型。
func (s *ServerChan3Sender) SendTextMessage(message, title string) error {
apiURL := strings.TrimSpace(s.Addition.APIURL)
if apiURL == "" {
return fmt.Errorf("未配置 Server酱³ 接口地址(api_url)")
}
// 简化校验:若标题与正文均为空则拒绝发送
finalTitle := strings.TrimSpace(title)
finalMessage := strings.TrimSpace(message)
if finalTitle == "" && finalMessage == "" {
return fmt.Errorf("serverchan3: 标题与正文均为空")
}
// 固定 JSON 载荷,包含 title、desp,并支持可选 tags(通过 | 分割)
payload := map[string]string{
"title": finalTitle,
"desp": finalMessage,
}
if tagsStr := strings.TrimSpace(s.Addition.Tags); tagsStr != "" {
parts := strings.Split(tagsStr, "|")
var cleaned []string
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
cleaned = append(cleaned, t)
}
}
if len(cleaned) > 0 {
// Server酱³ 校验提示需要字符串,这里按 | 拼接为字符串
payload["tags"] = strings.Join(cleaned, "|")
}
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("组装 JSON 失败: %v", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 固定设置 Content-Type
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// 读取响应体用于错误信息打印
b, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(b))
if msg == "" {
return fmt.Errorf("接口返回非 2xx 状态码: %d", resp.StatusCode)
}
return fmt.Errorf("接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
}
return nil
}
// 之前的模板、表单解析与手动转义工具不再需要,简化实现
// 确保实现 IMessageSender 接口
var _ factory.IMessageSender = (*ServerChan3Sender)(nil)
@@ -0,0 +1,25 @@
package serverchanturbo
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// Addition 为 Server酱 Turbo 推送通道的配置项
// 仅允许配置接口地址及可选的通道/隐藏IP/openid,固定以 JSON 发送
type Addition struct {
// APIURL 为接口完整地址,例如:https://sctapi.ftqq.com/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://sctapi.ftqq.com/<sendkey>.send;参考:https://sct.ftqq.com/"`
// Channel 为本次推送使用的消息通道,最多两个,多个用 | 隔开,例如:9|66
Channel string `json:"channel" help:"消息通道,可选,多个用 | 隔开,例如 9|66"`
// NoIP 是否隐藏调用 IP,填 1 则隐藏
NoIP string `json:"noip" help:"是否隐藏调用IP,填 1 隐藏;为空则不隐藏"`
// OpenID 消息抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔
OpenID string `json:"openid" help:"抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔"`
}
// 注册 Server酱 Turbo 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChanTurboSender{}
})
}
@@ -0,0 +1,95 @@
package serverchanturbo
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// ServerChanTurboSender 为 Server酱 Turbo 推送实现
type ServerChanTurboSender struct {
Addition
}
// GetName 返回推送通道名称
func (s *ServerChanTurboSender) GetName() string {
return "Server酱Turbo"
}
// GetConfiguration 返回配置结构体指针
func (s *ServerChanTurboSender) GetConfiguration() factory.Configuration {
return &s.Addition
}
// Init 初始化(当前无需处理)
func (s *ServerChanTurboSender) Init() error { return nil }
// Destroy 清理(当前无需处理)
func (s *ServerChanTurboSender) Destroy() error { return nil }
// SendTextMessage 固定以 JSON 发送,仅组装 title 与 desp,支持可选 channel/noip/openid
func (s *ServerChanTurboSender) SendTextMessage(message, title string) error {
apiURL := strings.TrimSpace(s.Addition.APIURL)
if apiURL == "" {
return fmt.Errorf("未配置 Server酱 Turbo 接口地址(api_url)")
}
finalTitle := strings.TrimSpace(title)
finalMessage := strings.TrimSpace(message)
if finalTitle == "" && finalMessage == "" {
return fmt.Errorf("serverchanturbo: 标题与正文均为空")
}
payload := map[string]interface{}{
"title": finalTitle,
"desp": finalMessage,
}
if ch := strings.TrimSpace(s.Addition.Channel); ch != "" {
payload["channel"] = ch
}
if noip := strings.TrimSpace(s.Addition.NoIP); noip == "1" {
payload["noip"] = "1"
}
if openid := strings.TrimSpace(s.Addition.OpenID); openid != "" {
payload["openid"] = openid
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("serverchanturbo: 组装 JSON 失败: %v", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("serverchanturbo: 创建请求失败: %v", err)
}
// 使用 JSON 传递参数,需要设置 Content-Type
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("serverchanturbo: 发送请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(b))
if msg == "" {
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d", resp.StatusCode)
}
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
}
return nil
}
// 确保实现 IMessageSender 接口
var _ factory.IMessageSender = (*ServerChanTurboSender)(nil)
+21
View File
@@ -0,0 +1,21 @@
package webhook
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
URL string `json:"url" required:"true"`
Method string `json:"method" default:"GET" type:"option" options:"POST,GET"`
ContentType string `json:"content_type" default:"application/json"`
Headers string `json:"headers" help:"HTTP headers in JSON format"`
Body string `json:"body" default:"{\"message\":\"{{message}}\",\"title\":\"{{title}}\"}"` // 默认使用message和title字段
Username string `json:"username"`
Password string `json:"password"`
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &WebhookSender{}
})
}
+185
View File
@@ -0,0 +1,185 @@
package webhook
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type WebhookSender struct {
Addition
}
func (w *WebhookSender) GetName() string {
return "webhook"
}
func (w *WebhookSender) GetConfiguration() factory.Configuration {
return &w.Addition
}
func (w *WebhookSender) Init() error {
return nil
}
func (w *WebhookSender) Destroy() error {
return nil
}
func (w *WebhookSender) SendTextMessage(message, title string) error {
if w.Addition.URL == "" {
return fmt.Errorf("webhook URL is not configured")
}
method := strings.ToUpper(w.Addition.Method)
if method == "" {
method = "GET" // 默认使用 GET
}
client := &http.Client{
Timeout: 30 * time.Second, // 固定30秒超时
}
var req *http.Request
var err error
switch method {
case "POST":
req, err = w.createPOSTRequest(message, title)
case "GET":
req, err = w.createGETRequest(message, title)
default:
return fmt.Errorf("unsupported HTTP method: %s", method)
}
if err != nil {
return fmt.Errorf("failed to create request: %v", err)
}
// 解析并设置自定义头部
if w.Addition.Headers != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(w.Addition.Headers), &headers); err == nil {
for key, value := range headers {
req.Header.Set(key, value)
}
}
}
// 设置基本认证
if w.Addition.Username != "" && w.Addition.Password != "" {
req.SetBasicAuth(w.Addition.Username, w.Addition.Password)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send webhook request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webhook request failed with status %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request, error) {
contentType := w.Addition.ContentType
if contentType == "" {
contentType = "application/json"
}
// 用户自定义模板,按 Content-Type 决定如何替换占位符
var body string
if isJSONContentType(contentType) {
body = w.replaceTemplateJSON(w.Addition.Body, message, title)
} else {
body = w.replaceTemplate(w.Addition.Body, message, title)
}
req, err := http.NewRequest("POST", w.Addition.URL, bytes.NewBufferString(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
return req, nil
}
func (w *WebhookSender) createGETRequest(message, title string) (*http.Request, error) {
URL := w.replaceTemplate(w.Addition.URL, message, title)
u, err := url.Parse(URL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %v", err)
}
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// replaceTemplate 替换模板中的 {{message}} 和 {{title}} 占位符(不做转义)
func (w *WebhookSender) replaceTemplate(template, message, title string) string {
result := template
result = strings.ReplaceAll(result, "{{message}}", message)
result = strings.ReplaceAll(result, "{{title}}", title)
return result
}
// replaceTemplateJSON 在 JSON 场景下替换占位符,使用 \uXXXX 形式进行转义(尤其换行/控制字符)
func (w *WebhookSender) replaceTemplateJSON(template, message, title string) string {
result := template
result = strings.ReplaceAll(result, "{{message}}", jsonUnicodeEscapeString(message))
result = strings.ReplaceAll(result, "{{title}}", jsonUnicodeEscapeString(title))
return result
}
// isJSONContentType 判断是否为 JSON 内容类型
func isJSONContentType(ct string) bool {
return strings.Contains(strings.ToLower(ct), "application/json")
}
// jsonUnicodeEscapeString 将字符串按 JSON 规则进行转义,并尽量使用 \uXXXX 形式(尤其控制字符、引号和反斜杠)
// 注意:该结果适合放入 JSON 字符串字面量的引号内部。
func jsonUnicodeEscapeString(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '"':
b.WriteString("\\u0022")
case '\\':
b.WriteString("\\u005C")
case '\b':
b.WriteString("\\u0008")
case '\f':
b.WriteString("\\u000C")
case '\n':
b.WriteString("\\u000A")
case '\r':
b.WriteString("\\u000D")
case '\t':
b.WriteString("\\u0009")
default:
if r < 0x20 {
// 其他控制字符
b.WriteString(fmt.Sprintf("\\u%04X", r))
} else {
// 其余字符按原样输出(UTF-8),JSON 允许非 ASCII 字符
b.WriteRune(r)
}
}
}
return b.String()
}
+10 -3
View File
@@ -7,6 +7,8 @@ import (
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
@@ -67,11 +69,16 @@ func CheckExpireScheduledWork() {
}
if len(clientLeadToExpire) > 0 {
message := "The following clients are about to expire: \n\n"
message := ""
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("• %s in %d days\n", clientInfo.Name, clientInfo.DaysLeft)
message += fmt.Sprintf("• %s (%dd)\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendTextMessage(message, "Komari Expiration Notification")
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Expire,
Time: time.Now(),
Message: message,
Emoji: "⏳",
})
}
}
+41 -14
View File
@@ -1,7 +1,6 @@
package notifier
import (
"fmt"
"log"
"reflect"
"sync"
@@ -10,6 +9,7 @@ import (
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/utils/messageSender"
)
@@ -140,22 +140,31 @@ func checkMetricThreshold(records []models.Record, task models.LoadNotification)
// getMetricValue 根据指标名称获取记录中的对应值
func getMetricValue(record models.Record, metric string) float32 {
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
switch metric {
case "cpu":
return record.Cpu
case "gpu":
return record.Gpu
case "net_in", "netin":
return bytesPerSecondToMbps(record.NetIn)
case "net_out", "netout":
return bytesPerSecondToMbps(record.NetOut)
case "ram":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.RamTotal > 0 {
return float32(record.Ram) / float32(client.MemTotal) * 100
}
return 0
case "swap":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.SwapTotal > 0 {
return float32(record.Swap) / float32(client.SwapTotal) * 100
}
@@ -165,6 +174,11 @@ func getMetricValue(record models.Record, metric string) float32 {
case "temp":
return record.Temp
case "disk":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.DiskTotal > 0 {
return float32(record.Disk) / float32(client.DiskTotal) * 100
}
@@ -187,22 +201,35 @@ func getMetricValue(record models.Record, metric string) float32 {
}
}
func bytesPerSecondToMbps(bytesPerSecond int64) float32 {
if bytesPerSecond <= 0 {
return 0
}
// 采用十进制 Mbps:1 Mbps = 1,000,000 bit/s
return float32(float64(bytesPerSecond) * 8 / 1_000_000)
}
// sendLoadNotification 发送负载通知
func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
message := "The following clients have exceeded the load threshold:\n\n"
ex_clients := []models.Client{}
for _, clientUUID := range clientUUIDs {
client, err := clients.GetClientByUUID(clientUUID)
if err != nil {
log.Printf("Failed to get client info for %s: %v", clientUUID, err)
continue
cl, err := clients.GetClientByUUID(clientUUID)
if err == nil {
ex_clients = append(ex_clients, cl)
}
message += fmt.Sprintf("%s\n", client.Name)
}
if len(clientUUIDs) == 0 {
if len(ex_clients) == 0 {
return
}
go func() {
messageSender.SendTextMessage(message, fmt.Sprintf("%s - Komari Load Alert", task.Name))
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Alert,
Clients: ex_clients,
Time: time.Now(),
Emoji: "⚠️",
Message: task.Name,
})
}()
}
+41 -11
View File
@@ -10,6 +10,7 @@ import (
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
@@ -20,6 +21,8 @@ type notificationState struct {
mu sync.Mutex // 互斥锁,保护该客户端状态
pendingOfflineSince time.Time // 客户端离线的时间。为零值表示客户端在线或已发送离线通知。
isFirstConnection bool // 标记是否为首次上线连接。
isConnExist bool // 标记是否存在连接
connectionID int64 // 连接ID,用于区分不同的连接会话,防止竞态条件
}
// clientStates 使用 sync.Map 实现对客户端状态的并发访问。
@@ -52,7 +55,7 @@ func getOrInitState(clientID string) *notificationState {
}
// OfflineNotification 在启用通知且未在宽限期内发送的情况下,发送客户端离线通知。
func OfflineNotification(clientID string) {
func OfflineNotification(clientID string, endedConnectionID int64) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
@@ -73,7 +76,8 @@ func OfflineNotification(clientID string) {
state.mu.Lock()
// 如果已处于待通知状态,则不做处理。
if !state.pendingOfflineSince.IsZero() {
// 只有当离线事件来自当前的连接会话时,我们才认为它有效。
if !state.pendingOfflineSince.IsZero() || state.connectionID != endedConnectionID {
state.mu.Unlock()
return
}
@@ -82,7 +86,7 @@ func OfflineNotification(clientID string) {
state.mu.Unlock()
// 新建协程,等待宽限期后判断是否需要发送通知。
go func(startTime time.Time) {
go func(startTime time.Time, expectedConnectionID int64) {
time.Sleep(gracePeriod)
state.mu.Lock()
@@ -90,18 +94,27 @@ func OfflineNotification(clientID string) {
// 检查离线状态是否仍为本次协程启动时的状态。
// 若为零值,说明客户端已重连。
// 若时间不同,说明客户端重连后又断开,已启动新计时。
if state.pendingOfflineSince.IsZero() || !state.pendingOfflineSince.Equal(startTime) {
// 当前的 connectionID 是否还是我们触发离线时的那个ID。如果不是,说明客户端重连过,本次离线通知已失效。
if state.pendingOfflineSince.IsZero() || state.connectionID != expectedConnectionID {
log.Printf("%s is reconnected new connID: %d, old connID: %d", clientID, state.connectionID, expectedConnectionID)
return
}
// 即将发送通知,重置待通知状态。
// 需要多一个boolean 是因为pendingOfflineSince在offline睡眠后才修改,可能导致online判断不对
state.pendingOfflineSince = time.Time{}
state.isConnExist = false
// Send notification
message := fmt.Sprintf("🔴%s is offline", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg, "Komari Offline Notification"); err != nil {
if err := messageSender.SendEvent(models.EventMessage{
Event: messageevent.Offline,
Clients: []models.Client{client},
Time: time.Now(),
//Message: msg,
Emoji: "🔴",
}); err != nil {
log.Println("Failed to send offline notification:", err)
}
}(message)
@@ -111,11 +124,11 @@ func OfflineNotification(clientID string) {
if err := db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).Update("last_notified", now).Error; err != nil {
log.Printf("Failed to update last_notified for client %s: %v", clientID, err)
}
}(now)
}(now, endedConnectionID)
}
// OnlineNotification 在启用通知的情况下,发送客户端上线通知。
func OnlineNotification(clientID string) {
func OnlineNotification(clientID string, connectionID int64) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
@@ -131,12 +144,14 @@ func OnlineNotification(clientID string) {
state.mu.Lock()
defer state.mu.Unlock()
state.connectionID = connectionID
// 规则1:首次连接不通知。
if state.isFirstConnection {
state.isFirstConnection = false
// 同时清除任何待离线状态(如服务器重启时客户端本已离线)
state.pendingOfflineSince = time.Time{}
state.isConnExist = true
return
}
@@ -145,15 +160,30 @@ func OnlineNotification(clientID string) {
// 上线时总是清除待离线状态。
state.pendingOfflineSince = time.Time{}
// 规则4:宽限期内重连,不通知。
// 规则2:宽限期内重连,不通知。
if wasPending {
return
}
// 规则3:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
// 规则3: 没断开后重连, 不通知
// 为了解决OfflineNotify中不是全程加锁
if state.isConnExist {
log.Printf("%s has connection exist: %d", clientID, connectionID)
return
} else {
state.isConnExist = true
}
// 规则4:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
message := fmt.Sprintf("🟢%s is online", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg, "Komari Online Notification"); err != nil {
if err := messageSender.SendEvent(models.EventMessage{
Event: messageevent.Online,
Clients: []models.Client{client},
Time: time.Now(),
//Message: msg,
Emoji: "🟢",
}); err != nil {
log.Println("Failed to send online notification:", err)
}
}(message)
+143
View File
@@ -0,0 +1,143 @@
package notifier
import (
"fmt"
"math"
"strings"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/ws"
cache "github.com/patrickmn/go-cache"
)
// trafficCache 用于记录每个客户端已触发的阈值步进,避免重复提醒
// key: "traffic:"+clientUUID, value: int 步进百分比(例如 80, 85, 90 ... 100)
var trafficCache = cache.New(30*24*time.Hour, time.Hour) // 30天缓存,1小时清理
// CheckTraffic 检查各客户端流量使用情况,并在达到阈值和每+5%时提醒一次;100%时额外提醒一次
// 由外部协程每分钟调用一次
func CheckTraffic() {
// 获取最新上报与客户端配置
reports := ws.GetLatestReport()
if len(reports) == 0 {
return
}
cfg, err := config.Get()
if err != nil {
return
}
if cfg.TrafficLimitPercentage <= 0 {
return
}
// 起始阈值:例如 80%,非5的倍数则从上取整到最近的5的倍数,例如 83->85
startThreshold := cfg.TrafficLimitPercentage
if startThreshold < 0 {
startThreshold = 0
}
baseStep := int(math.Ceil(startThreshold/5.0) * 5.0)
if baseStep > 100 {
baseStep = 100
}
allClients, err := clients.GetAllClientBasicInfo()
if err != nil {
return
}
for _, c := range allClients {
if c.TrafficLimit <= 0 {
continue
}
r, ok := reports[c.UUID]
if !ok || r == nil {
continue
}
// 计算不同类型的使用值
used := computeUsedByType(strings.ToLower(c.TrafficLimitType), r.Network.TotalUp, r.Network.TotalDown)
if used <= 0 {
continue
}
pct := float64(used) / float64(c.TrafficLimit) * 100.0
if pct < startThreshold {
continue
}
// 当前所在阈值步进(5%的倍数)
curStep := int(math.Floor(pct/5.0) * 5.0)
if curStep < baseStep {
curStep = baseStep
}
// if curStep > 100 {
// curStep = 100
// }
key := "traffic:" + c.UUID
last, _ := trafficCache.Get(key)
lastStep, _ := last.(int)
if curStep > lastStep { // 只在进入新步进时提醒一次
trafficCache.SetDefault(key, curStep)
msg := fmt.Sprintf("used %d%% (%s / %s), type=%s", curStep, humanBytes(used), humanBytes(c.TrafficLimit), strings.ToLower(c.TrafficLimitType))
// 发送通知(内部会检查 NotificationEnabled)
_ = messageSender.SendEvent(models.EventMessage{
Event: "Traffic",
Clients: []models.Client{c},
Time: time.Now(),
Emoji: "⚠️",
Message: msg,
})
}
}
}
func computeUsedByType(t string, up, down int64) int64 {
switch t {
case "up":
return up
case "down":
return down
case "sum":
return up + down
case "min":
if up < down {
return up
}
return down
case "max":
fallthrough
default:
if up > down {
return up
}
return down
}
}
func humanBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
// KMGTPE
prefixes := []string{"K", "M", "G", "T", "P", "E"}
if exp >= len(prefixes) {
exp = len(prefixes) - 1
}
return fmt.Sprintf("%.2f %sB", float64(b)/float64(div), prefixes[exp])
}
+3 -1
View File
@@ -1,11 +1,13 @@
package oauth
import (
_ "github.com/komari-monitor/komari/utils/oauth/cloudflare"
_ "github.com/komari-monitor/komari/utils/oauth/factory"
_ "github.com/komari-monitor/komari/utils/oauth/generic"
_ "github.com/komari-monitor/komari/utils/oauth/github"
_ "github.com/komari-monitor/komari/utils/oauth/qq"
)
func All() {
//empty function to ensure all OIDC providers are registered
}
}
+92
View File
@@ -0,0 +1,92 @@
package cloudflare
import (
"fmt"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
)
func (c *Cloudflare) GetName() string {
return "CloudflareAccess"
}
func (c *Cloudflare) GetConfiguration() factory.Configuration {
return &c.Addition
}
func (c *Cloudflare) GetAuthorizationURL(redirectURI string) (string, string) {
// 生成 state
state := utils.GenerateRandomString(8)
// 将 state 作为参数附加到回调 URL
sep := "?"
if strings.Contains(redirectURI, "?") {
sep = "&"
}
authURL := fmt.Sprintf("%s%sstate=%s", redirectURI, sep, state)
return authURL, state
}
func (c *Cloudflare) OnCallback(ctx *gin.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
// 从请求头中获取 Cloudflare Access JWT
accessJWT := ctx.GetHeader("Cf-Access-Jwt-Assertion")
if accessJWT == "" {
return factory.OidcCallback{}, fmt.Errorf("no Cloudflare Access JWT found")
}
// 验证 JWT
teamDomain := c.Addition.TeamDomain
// 如果不是完整的URL,则默认添加 https 前缀和 cloudflareaccess.com 后缀
if !strings.HasPrefix(teamDomain, "https://") {
teamDomain = "https://" + teamDomain + ".cloudflareaccess.com"
}
certsURL := fmt.Sprintf("%s/cdn-cgi/access/certs", teamDomain)
config := &oidc.Config{
ClientID: c.Addition.PolicyAUD,
}
keySet := oidc.NewRemoteKeySet(ctx.Request.Context(), certsURL)
verifier := oidc.NewVerifier(teamDomain, keySet, config)
// 验证 token
idToken, err := verifier.Verify(ctx.Request.Context(), accessJWT)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to verify Cloudflare Access token: %w", err)
}
// 提取用户信息
var claims struct {
// Email string `json:"email"`
Sub string `json:"sub"`
}
if err := idToken.Claims(&claims); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to extract claims from token: %w", err)
}
// 使用 sub 作为用户 ID
return factory.OidcCallback{UserId: claims.Sub}, nil
}
func (c *Cloudflare) Init() error {
if c.Addition.TeamDomain == "" {
return fmt.Errorf("team_domain is required")
}
if c.Addition.PolicyAUD == "" {
return fmt.Errorf("policy_aud is required")
}
return nil
}
func (c *Cloudflare) Destroy() error {
// Cloudflare Access 提供商不需要特殊的清理操作
return nil
}
var _ factory.IOidcProvider = (*Cloudflare)(nil)
+20
View File
@@ -0,0 +1,20 @@
package cloudflare
import (
"github.com/komari-monitor/komari/utils/oauth/factory"
)
func init() {
factory.RegisterOidcProvider(func() factory.IOidcProvider {
return &Cloudflare{}
})
}
type Cloudflare struct {
Addition
}
type Addition struct {
TeamDomain string `json:"team_domain" required:"true"`
PolicyAUD string `json:"policy_aud" required:"true"`
}
+3 -3
View File
@@ -1,14 +1,14 @@
package factory
import "context"
import "github.com/gin-gonic/gin"
type IOidcProvider interface {
GetName() string
// 请务必返回 &Configuration{} 的指针
GetConfiguration() Configuration
// 获取授权URL和状态
GetAuthorizationURL() (string, string)
OnCallback(ctx context.Context, state string, query map[string]string) (OidcCallback, error)
GetAuthorizationURL(redirectURI string) (string, string)
OnCallback(ctx *gin.Context, state string, query map[string]string, callbackURI string) (OidcCallback, error)
Init() error
Destroy() error
}
+4 -4
View File
@@ -22,10 +22,10 @@ func TestRegisterAndGetProviderConfigs(t *testing.T) {
t.Error("Expected non-empty OIDC provider names, got empty")
}
err := LoadProvider("github", `{"client_id":"test_id","client_secret":"test_secret"}`)
if err != nil {
t.Errorf("Failed to load provider: %v", err)
}
//err := LoadProvider("github", `{"client_id":"test_id","client_secret":"test_secret"}`)
//if err != nil {
// t.Errorf("Failed to load provider: %v", err)
//}
cfg := CurrentProvider().GetConfiguration()
if cfg == nil {
t.Error("Expected non-nil configuration for 'github' provider, got nil")
+11 -8
View File
@@ -1,13 +1,14 @@
package generic
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
@@ -20,21 +21,22 @@ func (g *Generic) GetConfiguration() factory.Configuration {
return &g.Addition
}
func (g *Generic) GetAuthorizationURL() (string, string) {
func (g *Generic) GetAuthorizationURL(redirectURI string) (string, string) {
state := utils.GenerateRandomString(16)
// 构建GitHub OAuth授权URL
authURL := fmt.Sprintf(
"%s?client_id=%s&state=%s&scope=%s&response_type=code",
"%s?client_id=%s&state=%s&scope=%s&redirect_uri=%s&response_type=code",
g.Addition.AuthURL,
url.QueryEscape(g.Addition.ClientId),
url.QueryEscape(state),
url.QueryEscape(g.Addition.Scope),
url.QueryEscape(redirectURI),
)
g.stateCache.Set(state, true, cache.DefaultExpiration)
return authURL, state
}
func (g *Generic) OnCallback(ctx context.Context, state string, query map[string]string) (factory.OidcCallback, error) {
func (g *Generic) OnCallback(ctx *gin.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
code := query["code"]
// 验证state防止CSRF攻击
@@ -58,16 +60,17 @@ func (g *Generic) OnCallback(ctx context.Context, state string, query map[string
"client_id": {g.Addition.ClientId},
"client_secret": {g.Addition.ClientSecret},
"code": {code},
"redirect_uri": {callbackURI},
"grant_type": {"authorization_code"},
}
req, _ := http.NewRequest("POST", g.Addition.TokenURL, nil)
req.URL.RawQuery = data.Encode()
req, _ := http.NewRequest("POST", g.Addition.TokenURL, strings.NewReader(data.Encode()))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
}
defer resp.Body.Close()
@@ -76,7 +79,7 @@ func (g *Generic) OnCallback(ctx context.Context, state string, query map[string
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
}
// 获取用户信息
+5 -5
View File
@@ -1,13 +1,13 @@
package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
@@ -24,7 +24,7 @@ func (g *Github) GetConfiguration() factory.Configuration {
return &g.Addition
}
func (g *Github) GetAuthorizationURL() (string, string) {
func (g *Github) GetAuthorizationURL(_ string) (string, string) {
state := utils.GenerateRandomString(16)
// 构建GitHub OAuth授权URL
@@ -36,7 +36,7 @@ func (g *Github) GetAuthorizationURL() (string, string) {
g.stateCache.Set(state, true, cache.NoExpiration)
return authURL, state
}
func (g *Github) OnCallback(ctx context.Context, state string, query map[string]string) (factory.OidcCallback, error) {
func (g *Github) OnCallback(ctx *gin.Context, state string, query map[string]string, _ string) (factory.OidcCallback, error) {
code := query["code"]
// 验证state防止CSRF攻击
@@ -71,7 +71,7 @@ func (g *Github) OnCallback(ctx context.Context, state string, query map[string]
resp, err := http.DefaultClient.Do(req)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
}
defer resp.Body.Close()
@@ -82,7 +82,7 @@ func (g *Github) OnCallback(ctx context.Context, state string, query map[string]
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
}
// 获取用户信息
+43 -27
View File
@@ -7,6 +7,7 @@ import (
"sync"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/oauth/factory"
)
@@ -26,33 +27,6 @@ func CurrentProvider() factory.IOidcProvider {
func LoadProvider(name string, configJson string) error {
mu.Lock()
defer mu.Unlock()
go func() {
once.Do(func() {
all := factory.GetAllOidcProviders()
for _, provider := range all {
if _, err := database.GetOidcConfigByName(provider.GetName()); err == nil {
continue
}
// 如果数据库中没有该提供者的配置,则保存默认配置
config := provider.GetConfiguration()
configBytes, err := json.Marshal(config)
if err != nil {
log.Printf("Failed to marshal config for provider %s: %v", provider.GetName(), err)
return
}
if err := database.SaveOidcConfig(&models.OidcProvider{
Name: provider.GetName(),
Addition: string(configBytes),
}); err != nil {
log.Printf("Failed to save default config for provider %s: %v", provider.GetName(), err)
return
}
}
})
}()
if currentProvider != nil && currentProvider.GetName() == name {
return nil // 已经加载了相同的提供程序
}
if currentProvider != nil {
if err := currentProvider.Destroy(); err != nil {
log.Printf("Failed to destroy provider %s: %v", currentProvider.GetName(), err)
@@ -72,3 +46,45 @@ func LoadProvider(name string, configJson string) error {
}
return nil
}
func Initialize() error {
once.Do(func() {
all := factory.GetAllOidcProviders()
for _, provider := range all {
if _, err := database.GetOidcConfigByName(provider.GetName()); err == nil {
continue
}
// 如果数据库中没有该提供者的配置,则保存默认配置
config := provider.GetConfiguration()
configBytes, err := json.Marshal(config)
if err != nil {
log.Printf("Failed to marshal config for provider %s: %v", provider.GetName(), err)
return
}
if err := database.SaveOidcConfig(&models.OidcProvider{
Name: provider.GetName(),
Addition: string(configBytes),
}); err != nil {
log.Printf("Failed to save default config for provider %s: %v", provider.GetName(), err)
return
}
}
})
cfg, _ := config.Get()
if cfg.OAuthProvider == "" || cfg.OAuthProvider == "none" {
LoadProvider("empty", "{}")
return nil
}
provider, err := database.GetOidcConfigByName(cfg.OAuthProvider)
if err != nil {
// 如果没有找到配置,使用empty provider
LoadProvider("empty", "{}")
return nil
}
err = LoadProvider(provider.Name, provider.Addition)
if err != nil {
log.Printf("Failed to load OIDC provider %s: %v", provider.Name, err)
return err
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package qq
import (
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func init() {
factory.RegisterOidcProvider(func() factory.IOidcProvider {
return &QQ{}
})
}
type QQ struct {
Addition
stateCache *cache.Cache // 用于存储state和用户信息的映射
}
type Addition struct {
AggregationURL string `json:"aggregation_url" required:"true" default:"https://login.qjqq.cn"` // 聚合登录地址
AppId string `json:"app_id" required:"true"`
AppKey string `json:"app_key" required:"true"`
LoginType string `json:"login_type" required:"true"` // 登录方式,如qq, google等
}
+171
View File
@@ -0,0 +1,171 @@
package qq
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func (q *QQ) GetName() string {
return "qq"
}
func (q *QQ) GetConfiguration() factory.Configuration {
return &q.Addition
}
func (q *QQ) GetAuthorizationURL(redirectURI string) (string, string) {
state := utils.GenerateRandomString(16)
// 构建请求QQ聚合登录平台的URL
requestURL := fmt.Sprintf(
"%s/connect.php?act=login&appid=%s&appkey=%s&type=%s&redirect_uri=%s",
q.Addition.AggregationURL,
url.QueryEscape(q.Addition.AppId),
url.QueryEscape(q.Addition.AppKey),
url.QueryEscape(q.Addition.LoginType),
url.QueryEscape(redirectURI),
)
// 向聚合登录平台发送请求
resp, err := http.Get(requestURL)
if err != nil {
// 如果请求失败,返回错误信息
return "", state
}
defer resp.Body.Close()
// 读取响应内容
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", state
}
// 解析响应JSON
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", state
}
// 检查响应状态
if result.Code != 0 {
return "", state
}
q.stateCache.Set(state, true, cache.DefaultExpiration)
return result.URL, state
}
// OnCallback 处理QQ OAuth回调
// 例如:http://localhost:25774/api/oauth_callback?type=qq&code=XXXXXXXXXXXXXXXX
// 然后我们使用code参数向聚合登录平台请求用户信息
func (q *QQ) OnCallback(ctx *gin.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
// 根据文档,回调地址会附带type和code参数
code := query["code"]
loginType := query["type"]
// 如果回调中没有type参数,则使用配置中的LoginType
if loginType == "" {
loginType = q.Addition.LoginType
}
// 验证state防止CSRF攻击
if q.stateCache == nil {
return factory.OidcCallback{}, fmt.Errorf("state cache not initialized")
}
if _, ok := q.stateCache.Get(state); !ok {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
if state == "" {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
// 检查是否提供了Authorization Code
if code == "" {
return factory.OidcCallback{}, fmt.Errorf("no authorization code provided")
}
// 通过Authorization Code获取用户信息
// 根据文档,请求URL应为: {AggregationURL}/connect.php?act=callback&appid={appid}&appkey={appkey}&type={登录方式}&code={code}
callbackURL := fmt.Sprintf(
"%s/connect.php?act=callback&appid=%s&appkey=%s&type=%s&code=%s",
q.Addition.AggregationURL,
url.QueryEscape(q.Addition.AppId),
url.QueryEscape(q.Addition.AppKey),
url.QueryEscape(loginType),
url.QueryEscape(code),
)
resp, err := http.Get(callbackURL)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get user info: %v", err)
}
defer resp.Body.Close()
// 检查HTTP响应状态
if resp.StatusCode != http.StatusOK {
return factory.OidcCallback{}, fmt.Errorf("HTTP request failed with status code: %d", resp.StatusCode)
}
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to read response: %v", err)
}
// 解析响应
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Type string `json:"type"`
SocialUid string `json:"social_uid"`
AccessToken string `json:"access_token"`
FaceImg string `json:"faceimg"`
Nickname string `json:"nickname"`
Gender string `json:"gender"`
Location string `json:"location"`
IP string `json:"ip"`
}
if err := json.Unmarshal(body, &result); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse callback response: %v, response body: %s", err, string(body))
}
// 检查返回状态码
if result.Code != 0 {
return factory.OidcCallback{}, fmt.Errorf("QQ login callback failed with code %d: %s", result.Code, result.Msg)
}
// 检查是否返回了用户唯一标识
if result.SocialUid == "" {
return factory.OidcCallback{}, fmt.Errorf("empty social_uid returned, full response: %s", string(body))
}
// 返回用户唯一标识
return factory.OidcCallback{UserId: result.SocialUid}, nil
}
func (q *QQ) Init() error {
q.stateCache = cache.New(time.Minute*5, time.Minute*10)
return nil
}
func (q *QQ) Destroy() error {
q.stateCache.Flush()
return nil
}
var _ factory.IOidcProvider = (*QQ)(nil)
+57 -38
View File
@@ -1,6 +1,7 @@
package utils
import (
"context"
"sync"
"time"
@@ -10,16 +11,13 @@ import (
// PingTaskManager 管理定时器和任务
type PingTaskManager struct {
mu sync.Mutex
tickers map[int]*time.Ticker
tasks map[int][]models.PingTask
stopChan chan struct{}
mu sync.Mutex
cancelFunc context.CancelFunc
tasks map[int][]models.PingTask
}
var manager = &PingTaskManager{
tickers: make(map[int]*time.Ticker),
tasks: make(map[int][]models.PingTask),
stopChan: make(chan struct{}),
tasks: make(map[int][]models.PingTask),
}
// Reload 重载时间表
@@ -27,60 +25,81 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
m.mu.Lock()
defer m.mu.Unlock()
// 停止所有现有定时器
for _, ticker := range m.tickers {
ticker.Stop()
if m.cancelFunc != nil {
m.cancelFunc()
}
m.tickers = make(map[int]*time.Ticker)
ctx, cancel := context.WithCancel(context.Background())
m.cancelFunc = cancel
m.tasks = make(map[int][]models.PingTask)
// 按Interval分组任务
taskGroups := make(map[int][]models.PingTask)
for _, task := range pingTasks {
if task.Interval <= 0 {
continue
}
taskGroups[task.Interval] = append(taskGroups[task.Interval], task)
}
// 为每个唯一的Interval创建定时器
// 为每个唯一的Interval创建协程
for interval, tasks := range taskGroups {
ticker := time.NewTicker(time.Duration(interval) * time.Second)
m.tickers[interval] = ticker
m.tasks[interval] = tasks
go func(ticker *time.Ticker, tasks []models.PingTask) {
for {
select {
case <-ticker.C:
for _, task := range tasks {
go executePingTask(task)
}
case <-m.stopChan:
return
}
}
}(ticker, tasks)
go m.runPreciseLoop(ctx, time.Duration(interval)*time.Second, tasks)
}
return nil
}
func (m *PingTaskManager) runPreciseLoop(ctx context.Context, interval time.Duration, tasks []models.PingTask) {
// Start the first timer.
timer := time.NewTimer(interval)
// This will be the reference point for all future ticks.
// By adding the interval to this time, we avoid accumulating execution delays.
nextTick := time.Now().Add(interval)
defer timer.Stop()
for {
select {
case <-timer.C:
onlineClients := ws.GetConnectedClients()
for _, task := range tasks {
go executePingTask(ctx, task, onlineClients)
}
nextTick = nextTick.Add(interval)
timer.Reset(time.Until(nextTick))
case <-ctx.Done():
return
}
}
}
// executePingTask 执行单个PingTask
func executePingTask(task models.PingTask) {
clients := ws.GetConnectedClients()
func executePingTask(ctx context.Context, task models.PingTask, onlineClients map[string]*ws.SafeConn) {
var message struct {
TaskID uint `json:"ping_task_id"`
Message string `json:"message"`
Type string `json:"ping_type"`
Target string `json:"ping_target"`
}
message.Message = "ping"
message.TaskID = task.Id
message.Type = task.Type
message.Target = task.Target
for _, clientUUID := range task.Clients {
if conn, exists := clients[clientUUID]; exists {
if conn == nil {
continue
}
message.Message = "ping"
message.TaskID = task.Id
message.Type = task.Type
message.Target = task.Target
select {
case <-ctx.Done():
// Context was canceled, stop sending pings.
return
default:
// Context is still active, continue.
}
if conn, exists := onlineClients[clientUUID]; exists && conn != nil {
if err := conn.WriteJSON(message); err != nil {
continue
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more