Compare commits

...
201 Commits
Author SHA1 Message Date
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
Akizon 56dd512d3c Merge pull request #157 from imlonghao/feat/5years
feat: 增加五年期的账单周期
2025-08-02 23:40:31 +08:00
Akizon 16f11b052a Merge pull request #158 from JohnsonRan/kernel-version
feat: add kernel_version field to Client model
2025-08-02 23:39:56 +08:00
JohnsonRan 06bae7efcb feat: add kernel_version field to Client model 2025-08-02 22:52:17 +08:00
imlonghao 39b017620f feat: 增加五年期的账单周期 2025-08-02 22:21:10 +08:00
Akizon77 45e8a3a530 Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-01 14:28:27 +00:00
Akizon77 8dd64a9fa3 fix: 更新OAuth回调中文案消息 2025-08-01 14:04:19 +00:00
Akizon c1e8b0779f Merge pull request #155 from komari-monitor/dev
feat: #149 支持超级群组话题
2025-08-01 21:53:47 +08:00
Akizon77 d16daf1862 feat: #149 支持超级群组话题 2025-08-01 13:45:06 +00:00
Akizon77 491b092ac1 feat: 添加Help字段到Item结构 2025-08-01 13:17:26 +00:00
Akizon77 e2304c6b1b refactor: 重构messageSender接口 2025-08-01 09:05:43 +00:00
Akizon77 ea52f61a2c Merge branch 'dev' 2025-08-01 06:53:12 +00:00
Akizon77 3db6edcc3d fix: 保存第三方登陆后会自动重载 2025-08-01 06:52:39 +00:00
Akizon77 2045dd3f30 Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-01 06:33:01 +00:00
Akizon77 7a37c93a02 fix: 修复私有站点中对非/api路径的放行逻辑 2025-08-01 06:32:26 +00:00
Akizon 1d49300f8c Merge pull request #154 from komari-monitor/dev
fix: OAuth校验state失效、打开私有化后客户点连接401的问题
2025-08-01 14:24:10 +08:00
Akizon77 f5641df0d9 fix: OAuth校验state失效 2025-08-01 06:22:10 +00:00
Akizon77 b9c1e58703 fix: /api/clients 绕过 2025-08-01 06:18:18 +00:00
Akizon77 22b9fada55 fix: 打开私有化后客户点连接401的问题 2025-07-31 16:07:00 +00:00
Akizon d34b286c4f Merge pull request #152 from komari-monitor/dev
refactor: 重构OAuth
2025-07-31 16:42:02 +08:00
Akizon77 6dab3c8450 refactor: 重写oauth逻辑 2025-07-31 08:23:34 +00:00
Akizon77 32dd5c9414 feat: OIDC接口 2025-07-31 07:24:00 +00:00
Akizon77 2f09d28b94 feat: 添加OIDC接口 2025-07-31 06:08:09 +00:00
Akizon77 77ce900bbf wip: 添加通用OIDC提供者实现及相关注册逻辑 2025-07-31 04:51:27 +00:00
Akizon77 0675634b27 wip: OIDC接口 2025-07-30 09:45:14 +00:00
Akizon77 74db186ab3 feat: 更新备份恢复成功消息并添加服务重启逻辑 2025-07-30 07:10:56 +00:00
Akizon 1a5bc31b9b Merge pull request #151 from komari-monitor/dev
feat: #143 添加全站私有化
2025-07-30 13:37:54 +08:00
Akizon77 f6a94db702 Merge branch 'main' into dev 2025-07-29 08:11:39 +00:00
Akizon77 6213ce7556 feat: #143 添加全站私有化 2025-07-29 08:11:27 +00:00
Akizon77 ff91e548b8 docs: 添加英语、日文和繁体中文的 README 文件 2025-07-29 07:28:26 +00:00
Akizon77 6d59070184 Merge branch 'main' into dev 2025-07-28 08:21:22 +00:00
Akizon77 c8126e551f refactor: 将 UTCTime 类型替换为 LocalTime 2025-07-28 08:20:42 +00:00
Akizon77 589eae0d3f ci: 更新清理旧包的工作流配置 2025-07-28 04:20:26 +00:00
Akizon77 8e25bc9ce3 ci: 删除没有标签的版本 2025-07-28 04:15:24 +00:00
Akizon77 9aa4dcc206 ci: 更新 Docker 发布工作流 2025-07-28 04:12:23 +00:00
Akizon77 b2b5966699 fix: 修改 Ping 记录时间格式为 RFC3339 2025-07-26 07:20:34 +00:00
Akizon77 e9c7e0031c fix: #146 优化 Ping 记录的输出结构 2025-07-26 07:19:31 +00:00
Akizon77 1e3d29ffdb Merge branch 'main' of https://github.com/komari-monitor/komari 2025-07-26 07:10:15 +00:00
Akizon77 84670ac231 feat: 恢复备份添加锁、添加备份标记文件并在上传时验证 2025-07-26 06:56:02 +00:00
Akizon 6b17c5c498 Update README.md 2025-07-26 14:11:21 +08:00
Akizon eed9820ce6 Merge pull request #148 from dbedu/main
feat: 备份文件恢复
2025-07-26 10:06:35 +08:00
dbedu 858d914ae3 新功能: 新增备份文件恢复 2025-07-26 00:46:07 +08:00
Akizon be7c3812db docs: 更新文档链接 2025-07-25 01:00:17 +08:00
Akizon 44c65f1eb8 docs: 容器云标注价格 2025-07-25 00:36:50 +08:00
Akizon dd742c1038 docs: 添加雨云一键部署 2025-07-25 00:16:53 +08:00
Akizon d19b64ec1d Merge pull request #144 from komari-monitor/dev
feat: 优化静态资源服务
2025-07-24 16:25:33 +08:00
Akizon77 6ea22e3241 feat: 优化静态资源服务 2025-07-24 08:10:09 +00:00
Akizon77 ea99966e7a feat: 配置获取添加并发控制 2025-07-24 08:09:57 +00:00
Akizon77 fa13d4d1f9 feat: 自定义端口 2025-07-23 14:31:45 +00:00
Akizon77 15e4f583e0 chore: 删除待办清单文件 2025-07-23 14:25:40 +00:00
Akizon77 3fbbc7bdce docs: 添加前端构建说明文档 2025-07-23 14:23:54 +00:00
Akizon77 5fcd1d313c fix: #138 Docker容器添加时区数据 2025-07-23 04:00:24 +00:00
Akizon 967b7ab8a7 docs: 更新文档链接 2025-07-22 20:52:24 +08:00
Akizon77 d113dde3cc docs: 添加容器云一键部署 2025-07-21 07:52:12 +00:00
Akizon77 60c24a5ee1 docs: 添加一键脚本的指南 2025-07-21 06:48:51 +00:00
Akizon77 d5b8982763 docs: 添加Telegram群组链接 2025-07-21 06:40:45 +00:00
Akizon77 88a441da23 feat: 添加manifest.json路由 2025-07-20 08:28:15 +00:00
akizon77 03f27e7eb1 docs: 更新鸣谢 2025-07-19 01:01:56 +08:00
Akizon 6bba4526f0 Merge pull request #129 from komari-monitor/dev
feat: 定时自动续费
2025-07-16 11:26:14 +08:00
Akizon77 ebb84862a2 feat: 定时自动续费 2025-07-16 11:16:05 +08:00
Akizon77 28480a93d4 feat: 自动续费 2025-07-16 11:07:27 +08:00
Akizon 5c7c5a4e99 Merge pull request #128 from komari-monitor/main
Sync to dev branch
2025-07-16 10:21:57 +08:00
Akizon 90ee1da9c7 Merge pull request #127 from ours1505/dev
feat: 自动续费
2025-07-16 10:18:56 +08:00
Nya 596dbd0c2d feat: 自动续费 2025-07-16 10:02:26 +08:00
Akizon de5db97505 Merge pull request #124 from komari-monitor/dev
docs: 更新前端开发指南
2025-07-15 16:33:32 +08:00
Akizon77 0db40294ba docs: 更新前端开发指南 2025-07-15 16:32:17 +08:00
Akizon 74cd074c98 Merge pull request #123 from komari-monitor/dev
feat: 终端页面不使用主题
2025-07-15 16:05:01 +08:00
Akizon77 bb43b25e37 fix: 主题short不可以为default 2025-07-15 14:44:14 +08:00
Akizon77 b28431e4bd feat: 终端页面不使用主题 2025-07-15 11:37:42 +08:00
Akizon77 ee999810af feat: 在删除客户端时同步删除ws报告 2025-07-15 11:32:36 +08:00
Akizon 860f498e21 Merge pull request #122 from komari-monitor/dev
docs: 更新readme
2025-07-14 23:55:07 +08:00
Akizon77 798e900a9a docs: 更新readme 2025-07-14 23:54:38 +08:00
Akizon 9617cf575f Merge pull request #121 from komari-monitor/dev
feat: 添加主题管理
2025-07-14 23:48:29 +08:00
Akizon77 0ebba0f591 feat: 添加主题管理功能,包括上传、列出、删除和设置主题 2025-07-14 23:23:47 +08:00
Akizon77 01fd429ec3 refactor: 提取RunServer 2025-07-14 19:18:32 +08:00
Akizon 701aacf45c Merge pull request #119 from komari-monitor/dev
feat: #107 支持自定义Tags
2025-07-14 19:13:13 +08:00
Akizon77 2120168d1f feat: #107 支持自定义Tags 2025-07-14 18:53:19 +08:00
Akizon 171e4ceece Merge pull request #117 from komari-monitor/dev
refactor: 替换logOperation为auditlog、将notification模块重构为notifier
2025-07-13 15:47:09 +08:00
Akizon77 1d4c394ad8 refactor: 将notification模块重构为notifier 2025-07-13 15:39:21 +08:00
Akizon77 837313e146 refactor: 替换logOperation为auditlog 2025-07-13 15:36:56 +08:00
Akizon 3e415da8d4 Merge pull request #114 from komari-monitor/dev
feat: admin页面不使用自定义样式
2025-07-12 18:42:05 +08:00
Akizon77 a125466b75 feat: admin页面不使用自定义样式 2025-07-12 18:39:04 +08:00
Akizon a9306cd651 Merge pull request #110 from komari-monitor/dev
feat: 添加客户端分组字段
2025-07-09 14:47:21 +08:00
Akizon77 6af72f41c0 feat: 添加客户端分组字段 2025-07-09 14:19:56 +08:00
Akizon77 e9e04593c7 feat: 负载通知 2025-07-08 23:31:20 +08:00
Akizon 43b39c9f68 Merge pull request #109 from komari-monitor/dev
refactor: 重建数据库关系
2025-07-08 16:58:28 +08:00
Akizon77 8d5ce55301 refactor: 离线通知不回传ClientInfo 2025-07-08 16:57:23 +08:00
Akizon77 e72d461869 refactor: 重建数据库关系 2025-07-08 16:52:21 +08:00
Akizon 5cc4b518ba Merge pull request #106 from komari-monitor/dev
feat: 管理脚本
2025-07-06 00:49:32 +08:00
Akizon77 8aed055cc4 feat: 精简安装脚本 2025-07-06 00:48:44 +08:00
Akizon77 a306b6911a feat: 显示默认登录信息 2025-07-05 23:29:59 +08:00
Akizon77 776dfee23b feat: 移除日志配置前缀颜色 2025-07-05 23:26:44 +08:00
Akizon77 02e01a1d7c feat: 修正二进制文件命名格式 2025-07-05 23:22:26 +08:00
Akizon77 6b0b5b4855 feat: 添加安装脚本 2025-07-05 23:17:46 +08:00
Akizon 9dd09731bd Merge pull request #100 from komari-monitor/dev
feat: 补全相关日志记录
2025-07-04 00:47:00 +08:00
Akizon77 963c829164 feat: 添加重试机制以增强消息发送的可靠性 2025-07-04 00:45:43 +08:00
Akizon77 d01fafbf0f feat: 更新终端相关日志记录 2025-07-04 00:42:50 +08:00
Akizon77 729db05346 feat: 增强cloudflared运行时日志记录,添加错误处理和成功退出日志 2025-07-04 00:35:25 +08:00
Akizon 70dc06a761 Merge pull request #90 from komari-monitor/dev
docs: 更新 Agent 开发指南
2025-07-01 22:40:38 +08:00
Akizon77 be059b7aa3 feat: 更新 Agent 开发指南 2025-07-01 22:39:36 +08:00
Akizon b80c917f25 Merge pull request #89 from komari-monitor/dev
fix: 数据库可能两次打包在压缩包内
2025-07-01 17:09:42 +08:00
Akizon77 6b74bf90ce fix: 数据库可能两次打包在压缩包内 2025-07-01 17:09:11 +08:00
Akizon a2e4665ff5 Merge pull request #88 from komari-monitor/dev
feat: 优化报告平均值计算
2025-07-01 16:57:04 +08:00
Akizon77 2c9bd9c847 feat: 修改AverageReport函数以支持topPercentage参数,优化报告平均值计算 2025-07-01 16:56:31 +08:00
Akizon77 54eb957618 feat: 为GeoIP服务提供者添加名称方法 2025-07-01 16:44:56 +08:00
Akizon 370f0ff815 Merge pull request #87 from komari-monitor/dev
feat: #68 集成cloudflared
2025-07-01 15:45:15 +08:00
Akizon77 9bd9a280e5 feat: #68 集成cloudflared 2025-07-01 15:34:00 +08:00
Akizon daf6ae291f Merge pull request #86 from komari-monitor/dev
feat: 添加IP查询缓存机制
2025-07-01 15:03:06 +08:00
Akizon77 3d774eec81 feat: 添加IP查询缓存机制 2025-07-01 14:58:56 +08:00
Akizon77 129e0be151 refactor: 简化GeoIP相关调用,可以插中间件了 2025-07-01 14:51:17 +08:00
Akizon 862657ea86 Merge pull request #83 from komari-monitor/dev
feat: 在获取的任务中添加间隔时间字段
2025-06-30 18:38:06 +08:00
Akizon77 70b519a345 feat: 在获取的任务中添加间隔时间字段 2025-06-30 18:09:31 +08:00
Akizon 6600e4014d Merge pull request #82 from komari-monitor/dev
feat: #53 通知方式兼容SMTP邮件、支持下载备份
2025-06-29 23:50:00 +08:00
Akizon77 b8a6f18a77 feat: 支持下载备份 2025-06-29 23:29:19 +08:00
Akizon77 03e08cdfbb fix: 只有在通知方式变动时重载MessageSenderProvider 2025-06-29 22:30:28 +08:00
Akizon77 b07f7345ee feat: 通知方式兼容SMTP邮件 2025-06-29 22:28:04 +08:00
Akizon77 e85996178a refactor: 抽象消息发送接口 2025-06-29 22:17:35 +08:00
Akizon77 5547fcd067 ci: 发布main分支的docker镜像,修正release 2025-06-29 21:54:41 +08:00
Akizon aef4344997 Merge pull request #81 from komari-monitor/dev
fix: #79 GeoIP无法更新
feat: IP查询兼容 ipinfo.io
2025-06-29 21:44:43 +08:00
Akizon77 061197cd77 ci: 发布到测试环境不显示部分日志 2025-06-29 21:40:14 +08:00
Akizon77 c00d1d0cb2 ci: 发布docker镜像时预编译二进制 2025-06-29 21:36:30 +08:00
Akizon77 b6a7511e33 fix: #79 GeoIP无法更新 2025-06-29 01:31:54 +08:00
Akizon77 13450a50eb feat: IP查询兼容 ipinfo.io 2025-06-28 21:04:37 +08:00
Akizon eb883ccc6f Merge pull request #78 from komari-monitor/dev
feat: 到期提醒显示具体还剩多少天
2025-06-28 20:20:43 +08:00
Akizon77 2ae9b3516c fix: MaxMind 数据库 initialize 方法需要在解锁后调用,以避免死锁 2025-06-28 20:15:31 +08:00
Akizon77 7dc2a0294e feat: 到期提醒显示具体还剩多少天 2025-06-28 20:02:32 +08:00
Akizon f97b91c19c Merge pull request #77 from komari-monitor/dev
feat: 添加过期通知功能,支持配置过期前通知天数
2025-06-27 23:10:54 +08:00
Akizon77 bfac98dc67 feat: 添加过期通知功能,支持配置过期前通知天数 2025-06-27 22:28:42 +08:00
Akizon77 0f0f9f5985 feat: 添加记录删除功能,支持在记录禁用时清除所有记录 2025-06-27 22:13:24 +08:00
Akizon efebe66518 Merge pull request #76 from komari-monitor/dev
feat: 添加对ip-api和geojs服务的支持
2025-06-27 21:38:10 +08:00
Akizon77 37a05e8f3d fix: 更新时间条件,调整为四小时之前的记录查询 2025-06-27 21:36:04 +08:00
Akizon77 7473c438c2 feat: 订阅配置变化更新geoip配置 2025-06-27 21:22:50 +08:00
Akizon77 6e4e6d0653 feat: 添加对ip-api和geojs服务的支持,更新GeoIP配置 2025-06-27 21:20:31 +08:00
Akizon77 c09bec5c20 refactor: 更新GeoIP服务实现 2025-06-27 21:05:59 +08:00
Akizon77 14ce2b8f53 fix: 调整高位百分比和删除旧数据的时间条件 2025-06-27 20:26:49 +08:00
Akizon 8ecb6aa7aa Merge pull request #75 from komari-monitor/dev
ci: docker发布
2025-06-27 17:36:26 +08:00
Akizon77 cedc144173 ci: docker发布 2025-06-27 17:34:26 +08:00
Akizon 434e09ae95 Merge pull request #74 from komari-monitor/dev
refactor: 将time.Time 迁移到UTCTime
2025-06-27 13:55:34 +08:00
Akizon77 06ffc44530 refactor: 将time.Time 迁移到UTCTime 2025-06-27 13:53:03 +08:00
Akizon a7c24e2323 Merge pull request #72 from komari-monitor/dev
ci: 限定Matrix
2025-06-25 23:15:26 +08:00
Akizon77 726cfa513e ci: 限定Matrix 2025-06-25 23:14:33 +08:00
Akizon 62a1317d88 Merge pull request #71 from komari-monitor/dev
ci: 使用Zig作为C编译器
2025-06-25 23:12:20 +08:00
Akizon77 1221feae45 ci: 使用Zig作为C编译器 2025-06-25 23:11:53 +08:00
Akizon 884c5686bd Merge pull request #70 from komari-monitor/dev
fix: #69  (概率)离线通知未按预期工作
2025-06-25 19:09:46 +08:00
Akizon77 9b82a19528 fix: #69 (概率)离线通知未按预期工作 2025-06-25 19:09:04 +08:00
Akizon 2db4dfb942 Merge pull request #67 from komari-monitor/dev
fix: 修复了每次建立连接都会通知客户端上线的问题
2025-06-24 14:52:07 +08:00
Akizon77 09df09c29a fix: 修复了每次建立连接都会通知客户端上线的问题 2025-06-24 14:51:27 +08:00
Akizon77 215fdc822c feat: 使用go-cache替代安全映射
ci: main自动发布附件
2025-06-24 14:36:30 +08:00
Akizon 1572b92751 Merge pull request #65 from komari-monitor/dev
fix: #57 api.Records引入线程安全的泛型映射
2025-06-23 23:39:40 +08:00
Akizon77 8da28d6b95 fix: #57 api.Records引入线程安全的泛型映射 2025-06-23 23:09:21 +08:00
Akizon 6f24268683 Merge pull request #63 from komari-monitor/dev
feat: 不再自动清除ping记录,重命名Telegram测试为发送消息
2025-06-22 23:17:46 +08:00
Akizon77 abe9de2bd1 feat: 在获取Ping记录特定任务过滤 2025-06-22 22:53:13 +08:00
Akizon77 342aacd060 fix: 只有订阅了ping task的节点能获取相应的task信息 2025-06-22 22:06:18 +08:00
Akizon77 a95d621bdb feat: 不再自动清除ping记录,重命名Telegram测试为发送消息 2025-06-22 21:53:19 +08:00
Akizon77 68a8f01b32 fix: 修正获取记录时的时间范围和分组逻辑 2025-06-22 19:33:43 +08:00
122 changed files with 6896 additions and 1166 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
+106
View File
@@ -0,0 +1,106 @@
name: Build Binaries on Main Push
on:
push:
branches:
- main
jobs:
build-binaries:
runs-on: ubuntu-latest
strategy:
matrix:
goos: [windows, linux]
goarch: [amd64, arm64, 386, riscv64]
include:
# Windows
- goos: windows
goarch: amd64
zig_target_triple: x86_64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: arm64
zig_target_triple: aarch64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: 386
zig_target_triple: x86-windows-gnu
binary_extension: .exe
# Linux
- goos: linux
goarch: amd64
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
exclude:
- goos: windows
goarch: riscv64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig cross-compiler toolchain
run: |
sudo apt-get update # Update apt package lists
sudo apt-get install -y build-essential curl
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
# Append the appropriate executable extension based on the OS
if [ "${{ matrix.binary_extension }}" != "" ]; then
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
fi
VERSION="${{ github.sha }}"
VERSION_HASH="${{ github.sha }}"
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
+32 -31
View File
@@ -20,40 +20,41 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
package_type: 'container',
package_name: process.env.GITHUB_REPOSITORY.split('/')[1]
// 分页获取所有包版本
const allVersions = [];
let page = 1;
let fetched;
const packageName = process.env.GITHUB_REPOSITORY.split('/')[1];
do {
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
package_type: 'container',
package_name: packageName,
page,
per_page: 100
});
fetched = packages.length;
allVersions.push(...packages);
page++;
} while (fetched === 100);
// 筛选仅有 SHA/sha256 标签或无标签的版本
const shaOrNoTagVersions = allVersions.filter(pkg => {
const tags = pkg.metadata.container.tags;
return (
(tags.length === 1 && (
tags[0].match(/^sha-[0-9a-f]{7}$/) ||
tags[0].match(/^sha256:[0-9a-f]{64}$/)
)) ||
tags.length === 0
);
});
// Keep packages with 'dev' or 'latest' tags, plus the 10 most recent non-dev, non-latest versions
const keepVersions = [
// Keep packages with 'dev' tag
...packages.filter(pkg => pkg.metadata.container.tags.includes('dev')),
// Keep packages with 'latest' tag, excluding those already included with 'dev'
...packages.filter(
pkg =>
pkg.metadata.container.tags.includes('latest') &&
!pkg.metadata.container.tags.includes('dev')
),
// Keep the 10 most recent non-dev, non-latest packages
...packages
.filter(
pkg =>
!pkg.metadata.container.tags.includes('dev') &&
!pkg.metadata.container.tags.includes('latest')
)
.slice(0, 10),
];
// Identify versions to delete (not in keepVersions)
const deleteVersions = packages.filter(pkg => !keepVersions.includes(pkg));
// Delete the versions
for (const version of deleteVersions) {
// 删除这些版本
for (const version of shaOrNoTagVersions) {
await github.rest.packages.deletePackageVersionForAuthenticatedUser({
package_type: 'container',
package_name: process.env.GITHUB_REPOSITORY.split('/')[1],
package_name: packageName,
package_version_id: version.id
});
console.log(`Deleted package version ${version.id}`);
}
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
}
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- name: Add host to known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy to production
run: |
+51 -5
View File
@@ -1,4 +1,4 @@
name: Docker Build and Publish
name: Publish Docker Image on Main
on:
push:
@@ -11,7 +11,7 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
build-and-push-multi-arch:
runs-on: ubuntu-latest
permissions:
contents: read
@@ -21,6 +21,54 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig
run: |
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build static binaries for multi-arch
env:
GOOS: linux
CGO_ENABLED: 1
GIN_MODE: release
run: |
VERSION="${{ github.ref_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
@@ -42,8 +90,6 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
VERSION_HASH=${{ github.sha }}
+98
View File
@@ -0,0 +1,98 @@
name: Publish Docker Image
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-multi-arch:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig
run: |
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build static binaries for multi-arch
env:
GOOS: linux
CGO_ENABLED: 1
GIN_MODE: release
run: |
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+41 -66
View File
@@ -1,4 +1,4 @@
name: Build and Attach Binaries to Release
name: Attach Release Binaries
on:
release:
@@ -19,26 +19,43 @@ jobs:
strategy:
matrix:
goos: [windows, linux]
goarch: [amd64, arm64]
goarch: [amd64, arm64, 386, riscv64]
include:
# Windows
- goos: windows
goarch: amd64
gcc: x86_64-w64-mingw32-gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
zig_target_triple: x86_64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: arm64
zig_target_triple: aarch64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: 386
zig_target_triple: x86-windows-gnu
binary_extension: .exe
# Linux
- goos: linux
goarch: amd64
gcc: gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
gcc: aarch64-linux-gnu-gcc
CGO_CFLAGS: '-pthread'
CGO_LDFLAGS: '-pthread'
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
exclude:
- goos: windows
goarch: arm64
goarch: riscv64
steps:
- name: Checkout code
@@ -64,81 +81,39 @@ jobs:
with:
go-version: "1.23"
- name: Install cross-compiler
- name: Install Zig cross-compiler toolchain
run: |
sudo apt-get update
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
sudo apt-get install gcc-aarch64-linux-gnu
fi
if [ "${{ matrix.goarch }}" = "amd64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install mingw-w64
fi
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install wget xz-utils
mkdir -p /tmp/mingw64
wget https://github.com/mstorsjo/llvm-mingw/releases/download/20250613/llvm-mingw-20250613-ucrt-ubuntu-22.04-aarch64.tar.xz -O /tmp/mingw64.tar.xz
tar -xf /tmp/mingw64.tar.xz -C /tmp/mingw64 --strip-components=1
fi
sudo apt-get update # Update apt package lists
sudo apt-get install -y build-essential curl
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: ${{ matrix.gcc }}
CGO_CFLAGS: ${{ matrix.CGO_CFLAGS }}
CGO_LDFLAGS: ${{ matrix.CGO_LDFLAGS }}
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME=${BINARY_NAME}.exe
if [ "${{ matrix.binary_extension }}" != "" ]; then
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
fi
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
- name: Upload binary to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Define the binary name for upload
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME=${BINARY_NAME}.exe
fi
# Upload the binary as a release asset using GitHub CLI
gh release upload ${{ github.event.release.tag_name }} $BINARY_NAME --repo ${{ github.repository }}
shell: bash
build-and-push-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.event.release.tag_name }}
VERSION_HASH=${{ github.sha }}
+1
View File
@@ -7,6 +7,7 @@
*.dll
*.so
*.dylib
__debug*
# local development data
komari.db
+11 -28
View File
@@ -1,34 +1,18 @@
FROM node:23-alpine AS frontend
WORKDIR /web
RUN apk add --no-cache git
RUN git clone https://github.com/komari-monitor/komari-web .
RUN npm install
RUN npm run build
FROM golang:1.24 AS builder
WORKDIR /app
# RUN apt-get update && apt-get install -y musl-tools
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /web/dist ./public/dist
ENV CGO_ENABLED=1
ARG VERSION=unknown
ARG VERSION_HASH=unknown
# RUN CC=musl-gcc go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static" -o komari .
RUN go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o komari .
# FROM scratch
FROM alpine:3.21
WORKDIR /app
COPY --from=builder /app/komari .
# Docker buildx 会在构建时自动填充这些变量
ARG TARGETOS
ARG TARGETARCH
RUN apk add --no-cache tzdata
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN chmod +x /app/komari
ENV GIN_MODE=release
# 数据库配置环境变量(可以在运行时覆盖)
ENV KOMARI_DB_TYPE=sqlite
ENV KOMARI_DB_FILE=/app/data/komari.db
ENV KOMARI_DB_HOST=localhost
@@ -40,5 +24,4 @@ ENV KOMARI_LISTEN=0.0.0.0:25774
EXPOSE 25774
# 使用环境变量启动服务
CMD ["/app/komari","server"]
CMD ["/app/komari", "server"]
+65 -78
View File
@@ -1,27 +1,45 @@
# Komari
# 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)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
![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)
[文档](https://komari-monitor.github.io/komari-document/)
[简体中文](./docs/README_zh.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.md)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
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)
### 依赖
- Docker(快速部署)
- 或者 Go 1.18+ 和 Node.js 20+(手工构建)
## Features
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
- **Self-hosted**: Complete control over data privacy, easy to deploy.
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
### Docker 部署
1. 创建数据目录:
## Quick Start
### 0. One-click Deployment with Cloud Hosting
- Rainyun - CNY 4.5/month
[![](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...).
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker Deployment
1. Create a data directory:
```bash
mkdir -p ./data
```
2. 运行 Docker 容器:
2. Run the Docker container:
```bash
docker run -d \
-p 25774:25774 \
@@ -29,104 +47,73 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看默认账号和密码:
3. View the default username and password:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
4. Access `http://<your_server_ip>:25774` in your browser.
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
### 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
### 3. Binary File Deployment
1. Visit Komari's [GitHub Release page](https://github.com/komari-monitor/komari/releases) to download the latest binary for your operating system.
2. Run Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
3. Access `http://<your_server_ip>:25774` in your browser. The default port is `25774`.
4. The default username and password can be found in the startup logs or set via the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
### 手工构建
1. 构建前端静态文件:
### Manual Build
#### Dependencies
- Go 1.18+ and Node.js 20+ (for manual build)
1. Build the frontend static files:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 构建后端:
2. Build the backend:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
```bash
go build -o komari
```
4. 运行:
4. Run:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
The default listening port is `25774`. Access `http://localhost:25774`.
## 前端开发指南
这个坑晚点再填吧 ヽ( ̄ω ̄( ̄ω ̄〃)ゝ
## Frontend Development Guide
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
## 客户端 Agent 开发指南
这个坑晚点再填吧 (o゜▽゜)o☆
[Translate Komari on Crowdin](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 贡献
欢迎提交 Issue 或 Pull Request!
## Client Agent Development Guide
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
## 鸣谢
- 感谢我自己能这么闲
- 广告位招租喵
## Contributing
Issues and Pull Requests are welcome!
## 引用
- [gorm.io](https://gorm.io/)
- [spf13/cobra](https://github.com/spf13/cobra)
- [oschwald/maxminddb-golang](https://github.com/oschwald/maxminddb-golang)
- [gorilla/websocket](https://github.com/gorilla/websocket)
- [google/uuid](https://github.com/google/uuid)
- [gin-gonic/gin](https://github.com/gin-gonic/gin)
- [UserExistsError/conpty](https://github.com/UserExistsError/conpty)
- [creack/pty](https://github.com/creack/pty)
- [rhysd/go-github-selfupdate](https://github.com/rhysd/go-github-selfupdate)
- [shirou/gopsutil](https://github.com/shirou/gopsutil)
## Acknowledgements
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- Thanks to myself for being so idle
- All the developers who submitted PRs and created themes
## 许可证
[MIT License](LICENSE)
## Star History
## 截图
![PixPin_2025-06-07_15-28-30](https://github.com/user-attachments/assets/edce5694-c6c8-4647-bc11-8b27105fd55c)
![PixPin_2025-06-07_15-28-49](https://github.com/user-attachments/assets/23b58032-211a-4b59-b444-c8267606a0bb)
![PixPin_2025-06-07_15-29-05](https://github.com/user-attachments/assets/f4325d4d-fa69-41c6-9251-19f530476e65)
![PixPin_2025-06-07_15-30-05](https://github.com/user-attachments/assets/69be38f6-6681-4afb-9a04-965d39b7fcc2)
![PixPin_2025-06-07_15-30-46](https://github.com/user-attachments/assets/01d84dd1-3c81-4424-a041-7dbc2ae5822e)
## 碎碎念
### 我为什么做Komari?
起因: [求推荐系统监控软件](https://www.nodeseek.com/post-133745-1)
> @古xx斯
> 哪吒?
> > 数据至少保存15天
> @sxxu
> 怕不是做梦的预算
> @Vxxn
> prometheus加grafana
> > 部署太麻烦
没有合适的,最近刚好时间稍微多了一些,就搓了 komari.
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+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.")
+60 -20
View File
@@ -1,6 +1,7 @@
package api
import (
"log"
"net/http"
"sync"
"time"
@@ -8,51 +9,79 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/patrickmn/go-cache"
"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"
)
var (
Records map[string][]common.Report = make(map[string][]common.Report)
Records = cache.New(1*time.Minute, 1*time.Minute)
)
type TerminalSession struct {
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
RequesterIp string
}
var TerminalSessionsMutex = &sync.Mutex{}
var TerminalSessions = make(map[string]*TerminalSession)
func SaveClientReportToDB() error {
lastMinute := time.Now().Add(-time.Minute * 1).Unix()
record := []models.Record{}
// 遍历所有的客户端记录
for uuid, reports := range Records {
// 删除一分钟前的记录
filtered := reports[:0]
lastMinute := time.Now().Add(-time.Minute).Unix()
var records []models.Record
// 遍历所有客户端记录
for uuid, x := range Records.Items() {
if uuid == "" {
continue
}
reports, ok := x.Object.([]common.Report)
if !ok {
log.Printf("Invalid report type for UUID %s", uuid)
continue
}
// 过滤一分钟前的记录
var filtered []common.Report
for _, r := range reports {
if r.UpdatedAt.Unix() >= lastMinute {
filtered = append(filtered, r)
}
}
Records[uuid] = filtered
if uuid == "" {
continue
// 更新缓存
Records.Set(uuid, filtered, cache.DefaultExpiration)
// 计算平均报告并添加到记录列表
if len(filtered) > 0 {
r := utils.AverageReport(uuid, time.Now(), filtered, 0.3)
records = append(records, r)
}
}
r := utils.AverageReport(uuid, time.Now(), filtered)
record = append(record, r)
// 批量插入数据库前去重(client与time共同构成唯一键)
if len(records) > 0 {
unique := make(map[string]models.Record)
for _, rec := range records {
key := rec.Client + "_" + strconv.FormatInt(rec.Time.ToTime().Unix(), 10)
unique[key] = rec
}
var deduped []models.Record
for _, rec := range unique {
deduped = append(deduped, rec)
}
db := dbcore.GetDBInstance()
err := db.Model(&models.Record{}).Where("client = ?", uuid).Create(record).Error
if err != nil {
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
log.Printf("Failed to save records to database: %v", err)
return err
}
}
@@ -91,3 +120,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
}
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/gin-gonic/gin"
)
var (
publicPaths = []string{
"/ping",
"/api/public",
"/api/login",
"/api/me",
"/api/oauth",
"/api/oauth_callback",
"/api/version",
"/api/admin", // 由AdminAuthMiddleware处理
"/api/clients/", // 由TokenAuthMiddleware处理
}
)
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 {
c.Next()
return
}
}
// 如果不是 /api,直接放行
if len(c.Request.URL.Path) < 4 || c.Request.URL.Path[:4] != "/api" {
c.Next()
return
}
conf, err := config.Get()
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
return
}
// 验证私有, 如果不是私有站点,直接放行
if !conf.PrivateSite {
c.Next()
return
}
// 如果是私有站点,检查是否有 session
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
return
}
_, err = accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
c.Next()
}
}
+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
+8 -5
View File
@@ -4,9 +4,10 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/ws"
)
func AddClient(c *gin.Context) {
@@ -28,7 +29,7 @@ func AddClient(c *gin.Context) {
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
auditlog.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
}
@@ -50,7 +51,7 @@ func EditClient(c *gin.Context) {
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
auditlog.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
@@ -65,8 +66,10 @@ func RemoveClient(c *gin.Context) {
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
c.JSON(200, gin.H{"status": "success"})
ws.DeleteConnectedClients(uuid)
ws.DeleteLatestReport(uuid)
}
func ClearRecord(c *gin.Context) {
@@ -78,7 +81,7 @@ func ClearRecord(c *gin.Context) {
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
auditlog.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
c.JSON(200, gin.H{"status": "success"})
}
+2 -2
View File
@@ -3,8 +3,8 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
)
@@ -23,6 +23,6 @@ func OrderWeight(c *gin.Context) {
}
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "order clients", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "order clients", "info")
api.RespondSuccess(c, nil)
}
+5 -5
View File
@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
)
@@ -49,7 +49,7 @@ func CreateClipboard(c *gin.Context) {
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
auditlog.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
api.RespondSuccess(c, req)
}
@@ -71,7 +71,7 @@ func UpdateClipboard(c *gin.Context) {
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
auditlog.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
api.RespondSuccess(c, nil)
}
@@ -88,7 +88,7 @@ func DeleteClipboard(c *gin.Context) {
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
auditlog.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
api.RespondSuccess(c, nil)
}
@@ -110,6 +110,6 @@ func BatchDeleteClipboard(c *gin.Context) {
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
auditlog.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
api.RespondSuccess(c, nil)
}
+236
View File
@@ -0,0 +1,236 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"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"
)
// copyDatabaseToTemp 安全地复制数据库文件到临时目录
// 对于SQLite数据库,会使用BACKUP命令确保数据一致性
func copyDatabaseToTemp(dbFilePath, tempDir string) (string, error) {
if dbFilePath == "" {
return "", fmt.Errorf("database file path is empty")
}
// 检查数据库文件是否存在
if _, err := os.Stat(dbFilePath); os.IsNotExist(err) {
return "", fmt.Errorf("database file does not exist: %s", dbFilePath)
}
dbFileName := filepath.Base(dbFilePath)
tempDbPath := filepath.Join(tempDir, dbFileName)
// 如果是SQLite数据库,使用SQLite的BACKUP命令来确保一致性
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
db := dbcore.GetDBInstance()
// 获取底层的SQLite连接
sqlDB, err := db.DB()
if err != nil {
return "", fmt.Errorf("failed to get underlying database connection: %v", err)
}
// 使用SQLite的BACKUP API来创建一致的备份
backupSQL := fmt.Sprintf("BACKUP DATABASE main TO '%s'", tempDbPath)
_, err = sqlDB.Exec(backupSQL)
if err != nil {
// 如果BACKUP命令失败,回退到文件复制方法
log.Printf("SQLite BACKUP command failed, falling back to file copy: %v", err)
return copyFileToTemp(dbFilePath, tempDbPath)
}
log.Printf("Database backed up using SQLite BACKUP to: %s", tempDbPath)
return tempDbPath, nil
} else {
// 对于非SQLite数据库,直接复制文件
return copyFileToTemp(dbFilePath, tempDbPath)
}
}
// copyFileToTemp 简单的文件复制到临时目录
func copyFileToTemp(srcPath, destPath string) (string, error) {
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()
_, err = io.Copy(dest, src)
if err != nil {
return "", fmt.Errorf("failed to copy file: %v", err)
}
log.Printf("Database file copied to: %s", destPath)
return destPath, nil
}
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
func DownloadBackup(c *gin.Context) {
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()
// 创建临时目录用于存放数据库备份
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) // 清理临时目录
err = filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 获取在 zip 包内的相对路径
relPath, err := filepath.Rel("./data", filePath)
if err != nil {
return err
}
// 跳过根目录本身,只打包内容
if relPath == "." {
return nil
}
// zip 内路径统一用正斜杠
zipPath := filepath.ToSlash(relPath)
if info.IsDir() {
// 在 zip 中创建目录项
_, err = zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath + "/",
Method: zip.Deflate, // zip.Store 不压缩
Modified: info.ModTime(),
})
return err
}
// 普通文件,写入 zip
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath,
Method: zip.Deflate,
Modified: info.ModTime(),
})
if err != nil {
return err
}
_, err = io.Copy(writer, file)
return err
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving data 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) {
// 使用安全的数据库复制方法
tempDbPath, err := copyDatabaseToTemp(dbFilePath, tempDir)
if err != nil {
if os.IsNotExist(err) {
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
} else {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
return
}
} else {
// 将临时数据库文件添加到zip
dbInfo, err := os.Stat(tempDbPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating temp database file: %v", err))
return
}
file, err := os.Open(tempDbPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening temp database file: %v", err))
return
}
defer file.Close()
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: filepath.Base(dbFilePath), // 使用原始数据库文件名
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(time.RFC3339)
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: "komari-backup-markup",
Method: zip.Deflate,
Modified: time.Now(),
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
return
}
_, err = markupWriter.Write([]byte(markupContent))
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
return
}
// zipWriter.Close() 由 defer 自动调用
}
+30 -13
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/logOperation"
"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
}
@@ -62,17 +73,23 @@ func Exec(c *gin.Context) {
}
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
auditlog.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
api.RespondSuccess(c, gin.H{
"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
// }
+63
View File
@@ -0,0 +1,63 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"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/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func GetMessageSenderProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetMessageSenderConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置项模板
providers := factory.GetSenderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No message sender providers found")
return
}
api.RespondSuccess(c, providers)
}
func SetMessageSenderProvider(c *gin.Context) {
var senderConfig models.MessageSenderProvider
if err := c.ShouldBindJSON(&senderConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if senderConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(senderConfig.Name)
if !exists {
api.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
return
}
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
return
}
cfg, _ := config.Get()
// 正在使用,重载
if cfg.NotificationMethod == senderConfig.Name {
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
if err != nil {
api.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
return
}
}
api.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
}
+90
View File
@@ -0,0 +1,90 @@
package notification
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
)
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
func AddLoadNotification(c *gin.Context) {
var req struct {
Clients []string `json:"clients" binding:"required"`
Name string `json:"name"`
Metric string `json:"metric" binding:"required"`
Threshold float32 `json:"threshold" binding:"required"` // 阈值百分比
Ratio float32 `json:"ratio" binding:"required"` // 达标时间比
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if req.Interval > 4*60 || req.Interval <= 0 {
api.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
return
}
if req.Ratio <= 0 || req.Ratio > 1 {
api.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
return
}
if taskID, err := notification.AddLoadNotification(req.Clients, req.Name, req.Metric, req.Threshold, req.Ratio, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
// POST body: id []uint
func DeleteLoadNotification(c *gin.Context) {
var req struct {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := notification.DeleteLoadNotification(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
}
}
// POST body: id []uint, updates map[string]interface{}
func EditLoadNotification(c *gin.Context) {
var req struct {
Notifications []*models.LoadNotification `json:"notifications" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := notification.EditLoadNotification(req.Notifications); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, notification := range req.Notifications {
// notification.DeleteLoadNotification([]uint{notification.Id})
// }
api.RespondSuccess(c, nil)
}
}
func GetAllLoadNotifications(c *gin.Context) {
notifications, err := notification.GetAllLoadNotifications()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, notifications)
}
+57
View File
@@ -3,7 +3,12 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/utils/oauth/factory"
)
func BindingExternalAccount(c *gin.Context) {
@@ -33,3 +38,55 @@ func UnbindExternalAccount(c *gin.Context) {
api.RespondSuccess(c, nil)
}
func GetOidcProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetOidcConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置
providers := factory.GetProviderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No OIDC providers found")
return
}
api.RespondSuccess(c, providers)
}
func SetOidcProvider(c *gin.Context) {
var oidcConfig models.OidcProvider
if err := c.ShouldBindJSON(&oidcConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if oidcConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(oidcConfig.Name)
if !exists {
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
}
cfg, _ := config.Get()
// 正在使用,重载
if cfg.OAuthProvider == oidcConfig.Name {
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
if err != nil {
api.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
return
}
}
api.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
}
+3 -3
View File
@@ -62,9 +62,9 @@ func EditPingTask(c *gin.Context) {
if err := tasks.EditPingTask(req.Tasks); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
for _, task := range req.Tasks {
tasks.DeletePingRecords([]uint{task.Id})
}
// for _, task := range req.Tasks {
// tasks.DeletePingRecords([]uint{task.Id})
// }
api.RespondSuccess(c, nil)
}
}
+3 -3
View File
@@ -3,7 +3,7 @@ package admin
import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/gin-gonic/gin"
)
@@ -33,7 +33,7 @@ func DeleteSession(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "delete session", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "delete session", "info")
api.RespondSuccess(c, nil)
}
@@ -45,6 +45,6 @@ func DeleteAllSession(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
auditlog.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
api.RespondSuccess(c, nil)
}
+12 -2
View File
@@ -4,9 +4,11 @@ import (
"database/sql"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/gin-gonic/gin"
)
@@ -57,7 +59,7 @@ func EditSettings(c *gin.Context) {
if len(message) > 2 {
message = message[:len(message)-2]
}
logOperation.Log(c.ClientIP(), uuid.(string), message, "info")
auditlog.Log(c.ClientIP(), uuid.(string), message, "info")
api.RespondSuccess(c, nil)
}
@@ -69,3 +71,11 @@ func contains(slice []string, item string) bool {
}
return false
}
func ClearAllRecords(c *gin.Context) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
api.RespondSuccess(c, nil)
}
+4 -4
View File
@@ -10,11 +10,11 @@ import (
"github.com/komari-monitor/komari/utils/messageSender"
)
func TestTelegram(c *gin.Context) {
func TestSendMessage(c *gin.Context) {
message := "Komari test message."
err := messageSender.SendTextMessage(message)
err := messageSender.SendTextMessage(message, "Komari Test Notification")
if err != nil {
api.RespondError(c, 500, "Failed to send Telegram message: "+err.Error())
api.RespondError(c, 500, "Failed to send message: "+err.Error())
return
}
api.RespondSuccess(c, nil)
@@ -38,7 +38,7 @@ func TestGeoIp(c *gin.Context) {
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.GetGeoIpInfo(net.ParseIP(ip))
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
if err != nil {
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
+589
View File
@@ -0,0 +1,589 @@
package admin
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"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"
)
// UploadTheme 上传主题
func UploadTheme(c *gin.Context) {
// 读取上传的文件内容
data, err := io.ReadAll(c.Request.Body)
if err != nil || len(data) == 0 {
api.RespondError(c, http.StatusBadRequest, "请选择要上传的主题文件")
return
}
// 临时文件名
tempFile := filepath.Join(os.TempDir(), "uploaded_theme.zip")
if err := os.WriteFile(tempFile, data, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
// 检查文件扩展名(这里假定上传的就是zip)
if !strings.HasSuffix(strings.ToLower(tempFile), ".zip") {
api.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
return
}
// 解压ZIP文件并验证
themeInfo, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
api.RespondSuccessMessage(c, "主题上传成功", themeInfo)
}
// ListThemes 列出所有主题
func ListThemes(c *gin.Context) {
dataDir := "./data/theme"
// 确保主题目录存在
if _, err := os.Stat(dataDir); os.IsNotExist(err) {
api.RespondSuccess(c, []models.Theme{})
return
}
entries, err := os.ReadDir(dataDir)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "读取主题目录失败: "+err.Error())
return
}
var themes []models.Theme
for _, entry := range entries {
if entry.IsDir() {
themeConfigPath := filepath.Join(dataDir, entry.Name(), "komari-theme.json")
if themeInfo, err := loadThemeConfig(themeConfigPath); err == nil {
themes = append(themes, themeInfo)
}
}
}
api.RespondSuccess(c, themes)
}
// DeleteTheme 删除主题
func DeleteTheme(c *gin.Context) {
var req struct {
Short string `json:"short" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.Short == "default" {
api.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
return
}
themeDir := filepath.Join("./data/theme", req.Short)
// 检查主题是否存在
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
// 删除主题目录
if err := os.RemoveAll(themeDir); err != nil {
api.RespondError(c, http.StatusInternalServerError, "删除主题失败: "+err.Error())
return
}
api.RespondSuccessMessage(c, "主题删除成功", nil)
}
// SetTheme 设置主题
func SetTheme(c *gin.Context) {
themeName := c.Query("theme")
if themeName == "" {
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
return
}
// 如果不是default主题,检查主题是否存在
if themeName != "default" {
themeDir := filepath.Join("./data/theme", themeName)
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
}
// 更新配置
updateData := map[string]interface{}{
"theme": themeName,
}
if err := config.Update(updateData); err != nil {
api.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
return
}
api.RespondSuccessMessage(c, "主题设置成功", gin.H{"theme": themeName})
}
// extractAndValidateTheme 解压并验证主题
func extractAndValidateTheme(zipPath string) (models.Theme, error) {
var themeInfo models.Theme
// 打开ZIP文件
r, err := zip.OpenReader(zipPath)
if err != nil {
return themeInfo, fmt.Errorf("无法打开ZIP文件: %v", err)
}
defer r.Close()
// 查找komari-theme.json文件
var themeConfigFile *zip.File
for _, f := range r.File {
if f.Name == "komari-theme.json" {
themeConfigFile = f
break
}
}
if themeConfigFile == nil {
return themeInfo, fmt.Errorf("主题配置文件 komari-theme.json 不存在")
}
// 读取主题配置
rc, err := themeConfigFile.Open()
if err != nil {
return themeInfo, fmt.Errorf("无法读取主题配置文件: %v", err)
}
defer rc.Close()
configData, err := io.ReadAll(rc)
if err != nil {
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
}
if err := json.Unmarshal(configData, &themeInfo); err != nil {
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
}
// 验证必填字段
if themeInfo.Name == "" || themeInfo.Short == "" {
return themeInfo, fmt.Errorf("主题配置缺少必填字段(name、short)")
}
// 验证Short字段格式(只允许字母、数字、下划线、连字符)
if !isValidThemeShort(themeInfo.Short) {
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
}
// 创建主题目录
themeDir := filepath.Join("./data/theme", themeInfo.Short)
// 如果目录已存在,先删除
if _, err := os.Stat(themeDir); err == nil {
if err := os.RemoveAll(themeDir); err != nil {
return themeInfo, fmt.Errorf("删除原有主题失败: %v", err)
}
}
if err := os.MkdirAll(themeDir, 0755); err != nil {
return themeInfo, fmt.Errorf("创建主题目录失败: %v", err)
}
// 解压文件到主题目录
for _, f := range r.File {
path := filepath.Join(themeDir, f.Name)
// 安全检查,防止路径遍历攻击
if !strings.HasPrefix(path, filepath.Clean(themeDir)+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
os.MkdirAll(path, f.FileInfo().Mode())
continue
}
// 创建目录
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return themeInfo, fmt.Errorf("创建目录失败: %v", err)
}
// 解压文件
rc, err := f.Open()
if err != nil {
return themeInfo, fmt.Errorf("打开压缩文件失败: %v", err)
}
outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.FileInfo().Mode())
if err != nil {
rc.Close()
return themeInfo, fmt.Errorf("创建文件失败: %v", err)
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return themeInfo, fmt.Errorf("解压文件失败: %v", err)
}
}
return themeInfo, nil
}
// loadThemeConfig 加载主题配置
func loadThemeConfig(configPath string) (models.Theme, error) {
var themeInfo models.Theme
data, err := os.ReadFile(configPath)
if err != nil {
return themeInfo, err
}
if err := json.Unmarshal(data, &themeInfo); err != nil {
return themeInfo, err
}
return themeInfo, nil
}
// isValidThemeShort 验证主题short字段格式
func isValidThemeShort(short string) bool {
if short == "" || short == "default" {
return false
}
for _, r := range short {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-') {
return false
}
}
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)
}
+3 -3
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
)
func UploadFavicon(c *gin.Context) {
@@ -27,7 +27,7 @@ func UploadFavicon(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
api.RespondSuccess(c, nil)
}
@@ -41,6 +41,6 @@ func DeleteFavicon(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
api.RespondSuccess(c, nil)
}
+3 -3
View File
@@ -3,16 +3,16 @@ package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/utils/geoip"
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateGeoIpDatabase(); err != nil {
if err := geoip.UpdateDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
api.RespondSuccess(c, nil)
}
+2 -2
View File
@@ -4,7 +4,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/auditlog"
)
func UpdateUser(c *gin.Context) {
@@ -35,6 +35,6 @@ func UpdateUser(c *gin.Context) {
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
auditlog.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
}
+173
View File
@@ -0,0 +1,173 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
)
// 只有一个备份恢复操作在进行
var restoreMutex sync.Mutex
// UploadBackup 用于接收上传的备份文件并将其内容恢复到原始位置
func UploadBackup(c *gin.Context) {
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
if !restoreMutex.TryLock() {
api.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
return
}
defer restoreMutex.Unlock()
// 获取上传的文件
file, header, err := c.Request.FormFile("backup")
if err != nil {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
return
}
defer file.Close()
// 检查文件是否为zip格式
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
api.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
return
}
// 创建临时文件保存上传的zip
tempFile, err := os.CreateTemp("", "backup-*.zip")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
return
}
tempFilePath := tempFile.Name()
defer os.Remove(tempFilePath) // 确保临时文件最终被删除
// 将上传的文件内容复制到临时文件
_, err = io.Copy(tempFile, file)
if err != nil {
tempFile.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
return
}
tempFile.Close() // 关闭文件以便后续操作
// 打开zip文件准备解压
zipReader, err := zip.OpenReader(tempFilePath)
if err != nil {
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)
}
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Backup restored successfully. The service will restart shortly.",
})
go func() {
log.Println("Backup restored, restarting service in 2 seconds...")
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})
}
+10 -7
View File
@@ -17,8 +17,9 @@ import (
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/notification"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/ws"
"github.com/patrickmn/go-cache"
)
func UploadReport(c *gin.Context) {
@@ -114,10 +115,10 @@ func WebSocketReport(c *gin.Context) {
return
}
ws.SetConnectedClients(uuid, conn)
go notification.OnlineNotification(uuid)
go notifier.OnlineNotification(uuid)
defer func() {
ws.DeleteConnectedClients(uuid)
notification.OfflineNotification(uuid)
notifier.OfflineNotification(uuid)
}()
for {
@@ -166,7 +167,7 @@ func WebSocketReport(c *gin.Context) {
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: reqBody.FinishedAt,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
default:
@@ -178,13 +179,15 @@ func WebSocketReport(c *gin.Context) {
}
func SaveClientReport(uuid string, report common.Report) error {
if api.Records[uuid] == nil {
api.Records[uuid] = []common.Report{}
reports, _ := api.Records.Get(uuid)
if reports == nil {
reports = []common.Report{}
}
if report.CPU.Usage < 0.01 {
report.CPU.Usage = 0.01
}
api.Records[uuid] = append(api.Records[uuid], report)
reports = append(reports.([]common.Report), report)
api.Records.Set(uuid, reports, cache.DefaultExpiration)
return nil
}
+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
}
+4 -4
View File
@@ -29,15 +29,15 @@ func UploadBasicInfo(c *gin.Context) {
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoIpInfo(ip4)
ip4_record, _ := geoip.GetGeoInfo(ip4)
if ip4_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.Country.ISOCode)
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
}
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
ip6 := net.ParseIP(ipv6)
ip6_record, _ := geoip.GetGeoIpInfo(ip6)
ip6_record, _ := geoip.GetGeoInfo(ip6)
if ip6_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.Country.ISOCode)
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
}
}
}
+2 -1
View File
@@ -10,5 +10,6 @@ func GetClientRecentRecords(c *gin.Context) {
RespondError(c, 400, "UUID is required")
return
}
RespondSuccess(c, Records[uuid])
records, _ := Records.Get(uuid)
RespondSuccess(c, records)
}
+3 -3
View File
@@ -6,8 +6,8 @@ import (
"net/http"
"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/database/logOperation"
"github.com/gin-gonic/gin"
)
@@ -63,13 +63,13 @@ func Login(c *gin.Context) {
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
auditlog.Log(c.ClientIP(), uuid, "logged in (password)", "login")
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
accounts.DeleteSession(session)
c.SetCookie("session_token", "", -1, "/", "", false, true)
logOperation.Log(c.ClientIP(), "", "logged out", "logout")
auditlog.Log(c.ClientIP(), "", "logged out", "logout")
c.Redirect(302, "/")
}
+15 -15
View File
@@ -5,8 +5,9 @@ import (
"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/database/logOperation"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth"
)
@@ -18,7 +19,7 @@ func OAuth(c *gin.Context) {
return
}
authURL, state := oauth.GetOAuthUrl()
authURL, state := oauth.CurrentProvider().GetAuthorizationURL(utils.GetCallbackURL(c))
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
@@ -35,21 +36,20 @@ func OAuthCallback(c *gin.Context) {
return
}
// 获取code
code := c.Query("code")
if code == "" {
c.JSON(400, gin.H{"status": "error", "error": "No code provided"})
return
queries := make(map[string]string)
for key, values := range c.Request.URL.Query() {
if len(values) > 0 {
queries[key] = values[0]
}
}
githubUser, err := oauth.OAuthCallback(code, state)
oidcUser, err := oauth.CurrentProvider().OnCallback(c.Request.Context(), state, queries, utils.GetCallbackURL(c))
if err != nil {
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
return
}
// 使用GitHub ID作为SSO ID
sso_id := fmt.Sprintf("github_%d", githubUser.ID)
// ID作为SSO ID
sso_id := fmt.Sprintf("%s_%s", oauth.CurrentProvider().GetName(), oidcUser.UserId)
// 如果cookie中有binding_external_account,说明是绑定外部账号
// 否则是登录
@@ -68,7 +68,7 @@ func OAuthCallback(c *gin.Context) {
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
return
}
logOperation.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
auditlog.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
c.Redirect(302, "/manage")
return
}
@@ -78,7 +78,7 @@ func OAuthCallback(c *gin.Context) {
if err != nil {
c.JSON(401, gin.H{
"status": "error",
"message": "please log in and bind your GitHub account first.",
"message": "please log in and bind your external account first.",
})
return
}
@@ -92,6 +92,6 @@ func OAuthCallback(c *gin.Context) {
// 设置cookie并返回
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
c.Redirect(302, "/manage")
auditlog.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
c.Redirect(302, "/admin")
}
+2
View File
@@ -18,11 +18,13 @@ func GetPublicSettings(c *gin.Context) {
"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,
})
}
+57 -7
View File
@@ -1,6 +1,7 @@
package record
import (
"slices"
"strconv"
"time"
@@ -42,6 +43,16 @@ func GetRecordsByUUID(c *gin.Context) {
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"` // 任务列表
}
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
@@ -63,25 +74,64 @@ func GetPingRecords(c *gin.Context) {
return
}
response := gin.H{
"records": records,
"count": len(records),
response := &Resp{
Count: len(records),
Records: []RecordsResp{},
}
for _, r := range records {
response.Records = append(response.Records, RecordsResp{
TaskId: r.TaskId,
Time: r.Time.ToTime().Format(time.RFC3339),
Value: r.Value,
})
}
if len(records) > 0 {
if len(records) >= 0 {
// 获取当前属于该 uuid 的 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 := slices.Contains(t.Clients, uuid)
if !found {
continue
}
// 计算该任务的丢包率
totalCount := 0
lossCount := 0
for _, r := range records {
if r.TaskId == t.Id {
totalCount++
if r.Value < 0 {
lossCount++
}
}
}
var lossRate float64 = 0
if totalCount > 0 {
lossRate = float64(lossCount) / float64(totalCount) * 100
}
tasksList = append(tasksList, gin.H{
"id": t.Id,
"name": t.Name,
"id": t.Id,
"name": t.Name,
"interval": t.Interval,
"loss": lossRate,
})
}
response["tasks"] = tasksList
response.Tasks = tasksList
}
api.RespondSuccess(c, response)
+9 -8
View File
@@ -7,8 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
)
@@ -41,10 +41,11 @@ func RequestTerminal(c *gin.Context) {
// 新建一个终端连接
id := utils.GenerateRandomString(32)
session := &TerminalSession{
UserUUID: user_uuid.(string),
UUID: uuid,
Browser: conn,
Agent: nil,
UserUUID: user_uuid.(string),
UUID: uuid,
Browser: conn,
Agent: nil,
RequesterIp: c.ClientIP(),
}
TerminalSessionsMutex.Lock()
@@ -95,7 +96,7 @@ func RequestTerminal(c *gin.Context) {
}
TerminalSessionsMutex.Unlock()
})
logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
//auditlog.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
}
func ForwardTerminal(id string) {
@@ -104,7 +105,7 @@ func ForwardTerminal(id string) {
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
return
}
logOperation.Log("", session.UserUUID, "established, terminal id:"+id, "terminal")
auditlog.Log(session.RequesterIp, session.UserUUID, "established, terminal id:"+id, "terminal")
established_time := time.Now()
errChan := make(chan error, 1)
@@ -161,7 +162,7 @@ func ForwardTerminal(id string) {
session.Browser.Close()
}
disconnect_time := time.Now()
logOperation.Log("", session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
auditlog.Log(session.RequesterIp, session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
+8 -8
View File
@@ -9,7 +9,7 @@ import (
"github.com/spf13/cobra"
)
func getEnv(key, defaultValue string) string {
func GetEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
@@ -19,13 +19,13 @@ func getEnv(key, defaultValue string) string {
// 从环境变量获取默认值
var (
dbTypeEnv = getEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = getEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = getEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = getEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = getEnv("KOMARI_DB_USER", "root")
dbPassEnv = getEnv("KOMARI_DB_PASS", "")
dbNameEnv = getEnv("KOMARI_DB_NAME", "komari")
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
)
var RootCmd = &cobra.Command{
+294 -206
View File
@@ -2,10 +2,12 @@ package cmd
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -20,16 +22,22 @@ import (
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database"
"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/database/dbcore"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
"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"
)
@@ -43,216 +51,288 @@ var ServerCmd = &cobra.Command{
Short: "Start the server",
Long: `Start the server`,
Run: func(cmd *cobra.Command, args []string) {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
go geoip.InitGeoIp()
go DoScheduledWork()
r := gin.Default()
// 动态 CORS 中间件
conf, err := config.Get()
if err != nil {
log.Fatal(err)
}
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
DynamicCorsEnabled = event.New.AllowCors
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "43200") // 12 hours
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
}
c.Next()
})
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", ws.GetClients)
r.GET("/api/nodes", api.GetNodesInformation)
r.GET("/api/public", api.GetPublicSettings)
r.GET("/api/oauth", api.OAuth)
r.GET("/api/oauth_callback", api.OAuthCallback)
r.GET("/api/logout", api.Logout)
r.GET("/api/version", api.GetVersion)
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
// #region Agent
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/telegram", test.TestTelegram)
testGroup.GET("/geoip", test.TestGeoIp)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
adminAuthrized.GET("/settings", admin.GetSettings)
adminAuthrized.POST("/settings", admin.EditSettings)
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
// #region 静态文件服务
public.UpdateIndex(conf)
config.Subscribe(func(event config.ConfigEvent) {
public.UpdateIndex(event.New)
})
srv := &http.Server{
Addr: flags.Listen,
Handler: r,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
OnShutdown()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
RunServer()
},
}
func init() {
// 从环境变量获取监听地址
listenAddr := getEnv("KOMARI_LISTEN", "0.0.0.0:25774")
listenAddr := GetEnv("KOMARI_LISTEN", "0.0.0.0:25774")
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
RootCmd.AddCommand(ServerCmd)
}
func RunServer() {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
// 创建主题目录
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
log.Fatalf("Failed to create theme directory: %v", err)
}
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
conf, err := config.Get()
if err != nil {
log.Fatal(err)
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
// oidcInit
go oauth.Initialize()
config.Subscribe(func(event config.ConfigEvent) {
if event.New.OAuthProvider != event.Old.OAuthProvider {
oidcProvider, err := database.GetOidcConfigByName(event.New.OAuthProvider)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
} else {
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
}
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
if err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
messageSender.Initialize()
}
})
// 初始化 cloudflared
if strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
if err != nil {
log.Fatalf("Failed to run cloudflared: %v", err)
}
}
r := gin.Default()
// 动态 CORS 中间件
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
DynamicCorsEnabled = event.New.AllowCors
if event.New.GeoIpProvider != event.Old.GeoIpProvider {
go geoip.InitGeoIp()
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
go messageSender.Initialize()
}
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "43200") // 12 hours
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
}
c.Next()
})
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", ws.GetClients)
r.GET("/api/nodes", api.GetNodesInformation)
r.GET("/api/public", api.GetPublicSettings)
r.GET("/api/oauth", api.OAuth)
r.GET("/api/oauth_callback", api.OAuthCallback)
r.GET("/api/logout", api.Logout)
r.GET("/api/version", api.GetVersion)
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
settingsGroup := adminAuthrized.Group("/settings")
{
settingsGroup.GET("/", admin.GetSettings)
settingsGroup.POST("/", admin.EditSettings)
settingsGroup.POST("/oidc", admin.SetOidcProvider)
settingsGroup.GET("/oidc", admin.GetOidcProvider)
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
}
// themes
themeGroup := adminAuthrized.Group("/theme")
{
themeGroup.PUT("/upload", admin.UploadTheme)
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
}
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
loadAlertGroup := notificationGroup.Group("/load")
{
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
loadAlertGroup.POST("/add", notification.AddLoadNotification)
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
}
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
// #region 静态文件服务
public.UpdateIndex(conf)
config.Subscribe(func(event config.ConfigEvent) {
public.UpdateIndex(event.New)
})
srv := &http.Server{
Addr: flags.Listen,
Handler: r,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
OnShutdown()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
}
func InitDatabase() {
// // 打印数据库类型和连接信息
// if flags.DatabaseType == "mysql" {
@@ -279,30 +359,38 @@ func InitDatabase() {
// #region 定时任务
func DoScheduledWork() {
tasks.ReloadPingSchedule()
d_notification.ReloadLoadNotificationSchedule()
ticker := time.NewTicker(time.Minute * 30)
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
go notifier.CheckExpireScheduledWork()
for {
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
logOperation.RemoveOldLogs()
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
auditlog.RemoveOldLogs()
case <-minute.C:
api.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
}
}
}
func OnShutdown() {
logOperation.Log("", "", "server is shutting down", "info")
auditlog.Log("", "", "server is shutting down", "info")
cloudflared.Kill()
}
func OnFatal(err error) {
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
cloudflared.Kill()
}
+2 -2
View File
@@ -100,8 +100,8 @@ func CreateDefaultAdminAccount() (username, passwd string, err error) {
Username: username,
Passwd: hashedPassword,
SSOID: "",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&user).Error
+3 -3
View File
@@ -27,11 +27,11 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
sessionRecord := models.Session{
UUID: uuid,
Session: session,
Expires: time.Now().Add(time.Duration(expires) * time.Second),
Expires: models.FromTime(time.Now().Add(time.Duration(expires) * time.Second)),
UserAgent: userAgent,
Ip: ip,
LoginMethod: login_method,
LatestOnline: time.Now(),
LatestOnline: models.FromTime(time.Now()),
}
err := db.Create(&sessionRecord).Error
@@ -50,7 +50,7 @@ func GetSession(session string) (uuid string, err error) {
return "", err
}
if time.Now().After(sessionRecord.Expires) {
if time.Now().After(sessionRecord.Expires.ToTime()) {
// 会话已过期,删除它
_ = DeleteSession(session)
return "", errors.New("session expired")
@@ -1,4 +1,4 @@
package logOperation
package auditlog
import (
"log"
@@ -16,11 +16,15 @@ func Log(ip, uuid, message, msgType string) {
UUID: uuid,
Message: message,
MsgType: msgType,
Time: now,
Time: models.FromTime(now),
}
db.Create(logEntry)
}
func EventLog(eventType, message string) {
Log("", "", message, eventType)
}
// Delete logs older than 30 days
func RemoveOldLogs() {
db := dbcore.GetDBInstance()
+4 -4
View File
@@ -180,8 +180,8 @@ func CreateClient() (clientUUID, token string, err error) {
UUID: clientUUID,
Token: token,
Name: "client_" + clientUUID[0:8],
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
@@ -202,8 +202,8 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
UUID: clientUUID,
Token: token,
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
+1 -1
View File
@@ -57,7 +57,7 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
Record := models.Record{
Client: clientUUID,
Time: time.Now(),
Time: models.FromTime(time.Now()),
Cpu: float32(report.CPU.Usage),
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
Ram: report.Ram.Used,
+14 -15
View File
@@ -3,6 +3,7 @@ package config
import (
"errors"
"log"
"sync"
"time"
"github.com/komari-monitor/komari/database/dbcore"
@@ -10,7 +11,15 @@ import (
"gorm.io/gorm"
)
var mu sync.Mutex
func init() {
mu = sync.Mutex{}
}
func Get() (models.Config, error) {
mu.Lock()
defer mu.Unlock()
db := dbcore.GetDBInstance()
var config models.Config
if err := db.First(&config).Error; err != nil {
@@ -22,9 +31,9 @@ func Get() (models.Config, error) {
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "mmdb",
UpdatedAt: time.Now(),
CreatedAt: time.Now(),
GeoIpProvider: "ip-api",
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)
@@ -41,19 +50,9 @@ func Save(cst models.Config) error {
oldConfig, _ := Get()
// Only one records
cst.ID = 1
cst.UpdatedAt = time.Now()
// Do not update CreatedAt
cst.UpdatedAt = models.FromTime(time.Now())
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
Select("sitename",
"description",
"allow_cors",
"geo_ip_enabled",
"geo_ip_provider",
"o_auth_client_id",
"o_auth_client_secret",
"o_auth_enabled",
"custom_head",
"updated_at").
Select("*").
Updates(cst).Error; err != nil {
return err
}
+129 -1
View File
@@ -1,6 +1,7 @@
package dbcore
import (
"encoding/json"
"fmt"
"log"
"sync"
@@ -50,7 +51,7 @@ func mergeClientInfo(db *gorm.DB) {
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
client.ExpiredAt = info.ExpiredAt
client.ExpiredAt = models.FromTime(info.ExpiredAt)
// Save updated Client record
if err := db.Save(&client).Error; err != nil {
log.Printf("Failed to update Client record: %v", err)
@@ -75,6 +76,131 @@ func MergeDatabase(db *gorm.DB) {
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
}
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
db.Migrator().DropTable(&models.LoadNotification{})
}
if !db.Migrator().HasTable(&models.OidcProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Merge OidcProvider table....")
var config struct {
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for OIDC provider migration:", err)
}
db.AutoMigrate(&models.OidcProvider{})
j, err := json.Marshal(&map[string]string{
"client_id": config.OAuthClientID,
"client_secret": config.OAuthClientSecret,
})
if err != nil {
log.Println("Failed to marshal OIDC provider config:", err)
return
}
db.Save(&models.OidcProvider{
Name: "github",
Addition: string(j),
})
db.AutoMigrate(&models.Config{})
db.Model(&models.Config{}).Where("id = 1").Update("o_auth_provider", "github")
}
if !db.Migrator().HasTable(&models.MessageSenderProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Migrate MessageSender configuration....")
var config struct {
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255)"`
EmailHost string `json:"email_host" gorm:"type:varchar(255)"`
EmailPort int `json:"email_port" gorm:"type:int"`
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"`
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"`
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"`
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"`
EmailUseSSL bool `json:"email_use_ssl" gorm:"type:boolean"`
NotificationMethod string `json:"notification_method" gorm:"type:varchar(50)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for MessageSender migration:", err)
}
db.AutoMigrate(&models.MessageSenderProvider{})
// 迁移Telegram配置
if config.NotificationMethod == "telegram" && config.TelegramBotToken != "" {
telegramConfig := map[string]interface{}{
"bot_token": config.TelegramBotToken,
"chat_id": config.TelegramChatID,
"endpoint": config.TelegramEndpoint,
}
if telegramConfig["endpoint"] == "" {
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
}
telegramConfigJSON, err := json.Marshal(telegramConfig)
if err != nil {
log.Println("Failed to marshal Telegram config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "telegram",
Addition: string(telegramConfigJSON),
})
}
}
// 迁移Email配置
if config.NotificationMethod == "email" && config.EmailHost != "" {
emailConfig := map[string]interface{}{
"host": config.EmailHost,
"port": config.EmailPort,
"username": config.EmailUsername,
"password": config.EmailPassword,
"sender": config.EmailSender,
"receiver": config.EmailReceiver,
"use_ssl": config.EmailUseSSL,
}
emailConfigJSON, err := json.Marshal(emailConfig)
if err != nil {
log.Println("Failed to marshal Email config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "email",
Addition: string(emailConfigJSON),
})
}
}
// 删除旧的配置字段
if db.Migrator().HasColumn(&models.Config{}, "telegram_bot_token") {
db.Migrator().DropColumn(&models.Config{}, "telegram_bot_token")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_chat_id") {
db.Migrator().DropColumn(&models.Config{}, "telegram_chat_id")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_endpoint") {
db.Migrator().DropColumn(&models.Config{}, "telegram_endpoint")
}
if db.Migrator().HasColumn(&models.Config{}, "email_host") {
db.Migrator().DropColumn(&models.Config{}, "email_host")
}
if db.Migrator().HasColumn(&models.Config{}, "email_port") {
db.Migrator().DropColumn(&models.Config{}, "email_port")
}
if db.Migrator().HasColumn(&models.Config{}, "email_username") {
db.Migrator().DropColumn(&models.Config{}, "email_username")
}
if db.Migrator().HasColumn(&models.Config{}, "email_password") {
db.Migrator().DropColumn(&models.Config{}, "email_password")
}
if db.Migrator().HasColumn(&models.Config{}, "email_sender") {
db.Migrator().DropColumn(&models.Config{}, "email_sender")
}
if db.Migrator().HasColumn(&models.Config{}, "email_receiver") {
db.Migrator().DropColumn(&models.Config{}, "email_receiver")
}
if db.Migrator().HasColumn(&models.Config{}, "email_use_ssl") {
db.Migrator().DropColumn(&models.Config{}, "email_use_ssl")
}
}
}
var (
@@ -136,6 +262,8 @@ func GetDBInstance() *gorm.DB {
&models.OfflineNotification{},
&models.PingRecord{},
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
+29
View File
@@ -0,0 +1,29 @@
package database
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetAllMessageSenderConfigs() []models.MessageSenderProvider {
db := dbcore.GetDBInstance()
var result []models.MessageSenderProvider
if err := db.Find(&result).Error; err != nil {
return nil
}
return result
}
func GetMessageSenderConfigByName(name string) (*models.MessageSenderProvider, error) {
db := dbcore.GetDBInstance()
var config models.MessageSenderProvider
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
func SaveMessageSenderConfig(config *models.MessageSenderProvider) error {
db := dbcore.GetDBInstance()
return db.Save(config).Error
}
+121
View File
@@ -0,0 +1,121 @@
package models
import (
"database/sql/driver"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
)
// LocalTime is a custom time type for GORM to handle time.Time correctly.
// It ensures that all times are stored and retrieved based on the application's configured timezone.
type LocalTime time.Time
// Value implements the driver.Valuer interface.
// It converts UTCTime to a TEXT format that the database understands.
// The time is formatted as a string in the application's local timezone, without timezone information.
func (t LocalTime) Value() (driver.Value, error) {
if time.Time(t).IsZero() {
return nil, nil
}
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
}
// Scan implements the sql.Scanner interface.
// It scans a value from the database into a UTCTime object.
func (t *LocalTime) Scan(v interface{}) error {
if v == nil {
*t = LocalTime(time.Time{})
return nil
}
loc := GetAppLocation()
switch val := v.(type) {
case time.Time:
// CRITICAL FIX: When the driver reads a timezone-less string (e.g., "16:00:00"),
// it often incorrectly assumes it's UTC. We must correct this by re-interpreting
// the date and clock values in the application's actual timezone.
year, month, day := val.Date()
hour, min, sec := val.Clock()
nanosec := val.Nanosecond()
*t = LocalTime(time.Date(year, month, day, hour, min, sec, nanosec, loc))
return nil
case []byte:
return t.parseTime(string(val), loc)
case string:
return t.parseTime(val, loc)
default:
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
}
}
// parseTime handles parsing a string into UTCTime.
func (t *LocalTime) parseTime(timeStr string, loc *time.Location) error {
timeStr = strings.TrimSpace(timeStr)
if timeStr == "" {
*t = LocalTime(time.Time{})
return nil
}
layouts := []string{
time.RFC3339Nano, time.RFC3339,
"2006-01-02 15:04:05.0000000-07:00", "2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05.0000000", "2006-01-02 15:04:05", "2006-01-02",
}
for _, layout := range layouts {
if parsedTime, err := time.ParseInLocation(layout, timeStr, loc); err == nil {
*t = LocalTime(parsedTime)
return nil
}
}
return fmt.Errorf("unable to parse time string '%s' into UTCTime", timeStr)
}
// MarshalJSON implements the json.Marshaler interface.
// Serializes UTCTime to JSON in RFC3339 format with the correct timezone offset.
func (t LocalTime) MarshalJSON() ([]byte, error) {
if time.Time(t).IsZero() {
return []byte("null"), nil
}
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
}
// ToTime converts UTCTime to Go's native time.Time type.
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
// FromTime converts Go's native time.Time type to UTCTime.
func FromTime(t time.Time) LocalTime { return LocalTime(t) }
// Now returns the current time in the application's configured location.
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
var (
appLocation *time.Location
locationOnce sync.Once
)
// GetAppLocation retrieves the application's global timezone from the "TZ" environment variable.
func GetAppLocation() *time.Location {
locationOnce.Do(func() {
tz := os.Getenv("TZ")
if tz == "" {
tz = "UTC"
}
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("Warning: Failed to load timezone '%s', falling back to UTC. Error: %v", tz, err)
appLocation = time.UTC
} else {
appLocation = loc
}
time.Local = appLocation
log.Printf("Application timezone is set to '%s'.", appLocation.String())
})
return appLocation
}
+2 -4
View File
@@ -1,13 +1,11 @@
package models
import "time"
type Clipboard struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
Text string `json:"text" gorm:"type:longtext"`
Name string `json:"name" gorm:"type:varchar(255)"`
Weight int `json:"weight" gorm:"type:int"`
Remark string `json:"remark" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
+17 -19
View File
@@ -1,35 +1,33 @@
package models
import "time"
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"`
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:''"` // 自动发现密钥
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'mmdb'"` // mmdb, bilibili, ip-api. 暂时只实现了mmdb
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// OAuth 配置
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// Telegram Bot 配置
// TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
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天
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:72"` // Ping 记录保留时间,单位小时,默认3天
CreatedAt time.Time
UpdatedAt time.Time
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt LocalTime
UpdatedAt LocalTime
}
+1 -3
View File
@@ -1,12 +1,10 @@
package models
import "time"
type Log struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
UUID string `json:"uuid" gorm:"type:varchar(36);"`
Message string `json:"message" gorm:"type:text;not null"`
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
Time time.Time `json:"time" gorm:"autoCreateTime;not null"`
Time LocalTime `json:"time" gorm:"autoCreateTime;not null"`
}
+6
View File
@@ -0,0 +1,6 @@
package models
type MessageSenderProvider struct {
Name string `json:"name" gorm:"primaryKey;unique;not null"`
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
}
+17 -13
View File
@@ -4,7 +4,6 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// Client represents a registered client device
@@ -17,6 +16,7 @@ type Client struct {
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)"`
@@ -30,10 +30,13 @@ type Client struct {
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 time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
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"`
}
// User represents an authenticated user
@@ -44,28 +47,29 @@ type User struct {
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// Session manages user sessions
type Session struct {
UUID string `json:"uuid" gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
Session string `json:"session" gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
UUID string `json:"uuid" gorm:"type:varchar(36)"`
Session string `json:"session" gorm:"type:varchar(255);primaryKey;uniqueIndex:idx_sessions_session;not null"`
UserAgent string `json:"user_agent" gorm:"type:text"`
Ip string `json:"ip" gorm:"type:varchar(100)"`
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
LatestOnline time.Time `json:"latest_online" gorm:"type:timestamp"`
LatestOnline LocalTime `json:"latest_online" gorm:"type:timestamp"`
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
Expires time.Time `json:"expires" gorm:"not null"`
CreatedAt time.Time `json:"created_at"`
Expires LocalTime `json:"expires" gorm:"not null"`
CreatedAt LocalTime `json:"created_at"`
}
// Record logs client metrics over time
type Record struct {
Client string `json:"client" gorm:"type:varchar(36);index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Time time.Time `json:"time" gorm:"index"`
Client string `json:"client" gorm:"type:varchar(36);index"`
Time LocalTime `json:"time" gorm:"index"`
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
Ram int64 `json:"ram" gorm:"type:bigint"`
+12 -13
View File
@@ -1,24 +1,23 @@
package models
import "time"
// Notification 定义了通知相关的数据库模型
type OfflineNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified time.Time `json:"last_notified"` // 上次通知时间
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
}
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
type LoadNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Window int `json:"window" gorm:"type:int;not null;default:300"` // 观测窗口(秒)
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒)
LastNotified time.Time `json:"last_notified"` // 上次通知时间
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"type:varchar(255)"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
}
+6
View File
@@ -0,0 +1,6 @@
package models
type OidcProvider struct {
Name string `json:"name" gorm:"primaryKey;unique;not null"`
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
}
+6 -6
View File
@@ -1,12 +1,12 @@
package models
import "time"
type PingRecord struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
TaskId uint `json:"task_id" gorm:"not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:TaskId;references:Id"`
Time time.Time `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
TaskId uint `json:"task_id" gorm:"not null;index"`
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
Time LocalTime `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
}
type PingTask struct {
+9 -10
View File
@@ -1,19 +1,18 @@
package models
import (
"time"
)
type Task struct {
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Command string `json:"command" gorm:"type:text"`
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Command string `json:"command" gorm:"type:text"`
Results []TaskResult `gorm:"foreignKey:TaskId;references:TaskId;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
}
type TaskResult struct {
TaskId string `json:"task_id" gorm:"type:varchar(36)"`
TaskId string `json:"task_id" gorm:"type:varchar(36);index"`
Client string `json:"client" gorm:"type:varchar(36)"`
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
Result string `json:"result" gorm:"type:longtext"`
ExitCode *int `json:"exit_code" gorm:"type:int"`
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp"`
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp"`
FinishedAt *LocalTime `json:"finished_at" gorm:"type:timestamp"`
CreatedAt LocalTime `json:"created_at" gorm:"type:timestamp"`
}
+12
View File
@@ -0,0 +1,12 @@
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"` // 预览图片相对路径
}
+68
View File
@@ -0,0 +1,68 @@
package notification
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/notifier"
"gorm.io/gorm"
)
func AddLoadNotification(clients []string, name string, metric string, threshold float32, ratio float32, interval int) (uint, error) {
db := dbcore.GetDBInstance()
notification := models.LoadNotification{
Clients: clients,
Name: name,
Metric: metric,
Threshold: threshold,
Ratio: ratio,
Interval: interval,
}
if err := db.Create(&notification).Error; err != nil {
return 0, err
}
return notification.Id, ReloadLoadNotificationSchedule()
}
func DeleteLoadNotification(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("id IN ?", id).Delete(&models.LoadNotification{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return ReloadLoadNotificationSchedule()
}
func EditLoadNotification(notifications []*models.LoadNotification) error {
db := dbcore.GetDBInstance()
for _, notification := range notifications {
result := db.Model(&models.LoadNotification{}).Where("id = ?", notification.Id).Updates(notification)
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
}
return ReloadLoadNotificationSchedule()
}
func GetAllLoadNotifications() ([]models.LoadNotification, error) {
db := dbcore.GetDBInstance()
var notifications []models.LoadNotification
if err := db.Find(&notifications).Error; err != nil {
return nil, err
}
return notifications, nil
}
func SaveLoadNotification(record models.LoadNotification) error {
db := dbcore.GetDBInstance()
return db.Create(&record).Error
}
func ReloadLoadNotificationSchedule() error {
db := dbcore.GetDBInstance()
var loadNotifications []models.LoadNotification
if err := db.Find(&loadNotifications).Error; err != nil {
return err
}
return notifier.ReloadLoadNotificationSchedule(loadNotifications)
}
-1
View File
@@ -1 +0,0 @@
package notification
+32
View File
@@ -0,0 +1,32 @@
package database
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetAllOidcConfigs() []models.OidcProvider {
db := dbcore.GetDBInstance()
var result []models.OidcProvider
if err := db.Find(&result).Error; err != nil {
return nil
}
return result
}
func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
db := dbcore.GetDBInstance()
var config models.OidcProvider
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
func SaveOidcConfig(config *models.OidcProvider) error {
db := dbcore.GetDBInstance()
if err := db.Save(config).Error; err != nil {
return err
}
return nil
}
+37 -35
View File
@@ -42,49 +42,50 @@ func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Reco
db := dbcore.GetDBInstance()
var records []models.Record
threeHoursAgo := time.Now().Add(-3 * time.Hour)
interval := end.Sub(start)
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
if end.After(threeHoursAgo) {
var recentRecords []models.Record
recentStart := start
if recentStart.Before(threeHoursAgo) {
recentStart = threeHoursAgo
var recentRecords []models.Record
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").Find(&recentRecords).Error
if err != nil {
log.Printf("Error fetching recent records for client %s between %s and %s: %v", uuid, recentStart, end, err)
return nil, err
}
if interval > 4*time.Hour {
// 按15分钟分组,每组只保留一条(取最新一条)
grouped := make(map[string]models.Record)
for _, rec := range recentRecords {
key := rec.Time.Truncate(15 * time.Minute).Format(time.RFC3339)
if old, ok := grouped[key]; !ok || rec.Time.After(old.Time) {
grouped[key] = rec
}
}
var groupedList []models.Record
for _, rec := range grouped {
groupedList = append(groupedList, rec)
}
sort.Slice(groupedList, func(i, j int) bool {
return groupedList[i].Time.Before(groupedList[j].Time)
})
records = append(records, groupedList...)
} else {
// 查询区间不超过,直接返回全部recentRecords
records = append(records, recentRecords...)
}
}
var long_term []models.Record
err := db.Table("records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records for client %s between %s and %s: %v", uuid, start, end, err)
return recentRecords, nil
}
if len(long_term) == 0 {
// 没有查到long_term,返回全部recentRecords
records = append(records, recentRecords...)
return records, nil
}
// 查到了long_term,recentRecords按15分钟分组,每组只保留一条(取最新一条)
grouped := make(map[string]models.Record)
for _, rec := range recentRecords {
key := rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)
if old, ok := grouped[key]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
grouped[key] = rec
}
}
var groupedList []models.Record
for _, rec := range grouped {
groupedList = append(groupedList, rec)
}
sort.Slice(groupedList, func(i, j int) bool {
return groupedList[i].Time.ToTime().Before(groupedList[j].Time.ToTime())
})
records = append(records, groupedList...)
records = append(records, long_term...)
return records, nil
}
@@ -121,7 +122,7 @@ func CompactRecord() error {
log.Printf("Error vacuuming database: %v", err)
}
}
log.Printf("Record compaction completed")
//log.Printf("Record compaction completed")
return nil
}
@@ -162,7 +163,7 @@ func migrateOldRecords(db *gorm.DB) error {
groupedRecords := make(map[string]*groupData)
for _, record := range records {
key := record.Client + "_" + record.Time.Truncate(15*time.Minute).Format(time.RFC3339)
key := record.Client + "_" + record.Time.ToTime().Truncate(15*time.Minute).Format(time.RFC3339)
if _, ok := groupedRecords[key]; !ok {
groupedRecords[key] = &groupData{}
}
@@ -250,7 +251,8 @@ func migrateOldRecords(db *gorm.DB) error {
for i, v := range data.Temp {
tempFloats[i] = float64(v)
}
high_percentile := 0.8
// 取高位
high_percentile := 0.7
// 检查 records_long_term 表中是否已存在相同的记录
var existingCount int64
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Count(&existingCount).Error; err != nil {
@@ -259,7 +261,7 @@ func migrateOldRecords(db *gorm.DB) error {
newRec := models.Record{
Client: clientUUID,
Time: timeSlot,
Time: models.FromTime(timeSlot),
Cpu: float32(getPercentile(cpuFloats, high_percentile)),
Gpu: float32(getPercentile(gpuFloats, high_percentile)),
Load: float32(getPercentile(loadFloats, high_percentile)),
@@ -270,8 +272,8 @@ func migrateOldRecords(db *gorm.DB) error {
SwapTotal: getIntPercentile(data.SwapTotal, high_percentile),
Disk: getIntPercentile(data.Disk, high_percentile),
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
NetIn: getIntPercentile(data.NetIn, high_percentile),
NetOut: getIntPercentile(data.NetOut, high_percentile),
NetIn: getIntPercentile(data.NetIn, 0.2),
NetOut: getIntPercentile(data.NetOut, 0.2),
NetTotalUp: getIntPercentile(data.NetTotalUp, high_percentile),
NetTotalDown: getIntPercentile(data.NetTotalDown, high_percentile),
Process: getInt32Percentile(data.Process, high_percentile),
@@ -292,7 +294,7 @@ func migrateOldRecords(db *gorm.DB) error {
}
// 删除 records 表中的旧数据
if err := tx.Table("records").Where("time < ?", fourHoursAgo).Delete(&models.Record{}).Error; err != nil {
if err := tx.Table("records").Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
return err
}
+4 -4
View File
@@ -40,7 +40,7 @@ func TestCompactRecord(t *testing.T) {
// 插入数据
for i := 0; i < totalMinutes; i++ {
recTime := now.Add(-time.Duration(i) * time.Minute)
rec := models.Record{Client: uuid, Time: recTime, Cpu: float32(i), Gpu: float32(i), Load: float32(i), Temp: float32(i), Ram: int64(i)}
rec := models.Record{Client: uuid, Time: models.FromTime(recTime), Cpu: float32(i), Gpu: float32(i), Load: float32(i), Temp: float32(i), Ram: int64(i)}
err := db.Create(&rec).Error
assert.NoError(t, err)
@@ -65,7 +65,7 @@ func TestCompactRecord(t *testing.T) {
for _, r := range origRecs {
wOrig.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
r.Time.ToTime().Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
@@ -100,7 +100,7 @@ func TestCompactRecord(t *testing.T) {
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
r.Time.ToTime().Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
@@ -119,7 +119,7 @@ func TestCompactRecord(t *testing.T) {
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
r.Time.ToTime().Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
+8
View File
@@ -85,6 +85,14 @@ func DeletePingRecords(id []uint) error {
return result.Error
}
func DeleteAllPingRecords() error {
db := dbcore.GetDBInstance()
result := db.Exec("DELETE FROM ping_records")
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
}
func ReloadPingSchedule() error {
db := dbcore.GetDBInstance()
var pingTasks []models.PingTask
+2 -2
View File
@@ -26,7 +26,7 @@ func CreateTask(taskId string, clients []string, command string) error {
Result: "",
ExitCode: nil,
FinishedAt: nil,
CreatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
})
}
if len(taskResults) > 0 {
@@ -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).
+115
View File
@@ -0,0 +1,115 @@
# 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)
![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)
Komariは、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Webインターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
## 特徴
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
## クイックスタート
### 0. クラウドホスティングによるワンクリック展開
- Rainyun - CNY 4.5/月
[![](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
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker展開
1. データディレクトリを作成します:
```bash
mkdir -p ./data
```
2. Dockerコンテナを実行します:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. デフォルトのユーザー名とパスワードを表示します:
```bash
docker logs komari
```
4. ブラウザで `http://<your_server_ip>:25774` にアクセスします。
> [!NOTE]
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
### 3. バイナリファイル展開
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komariを実行します:
```bash
./komari server -l 0.0.0.0:25774
```
3. ブラウザで `http://<your_server_ip>:25774` にアクセスします。デフォルトのポートは `25774` です。
4. デフォルトのユーザー名とパスワードは、起動ログで確認するか、環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を介して設定できます。
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
### 手動ビルド
#### 依存関係
- Go 1.18+ および Node.js 20+(手動ビルド用)
1. フロントエンドの静的ファイルをビルドします:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. バックエンドをビルドします:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
```bash
go build -o komari
```
4. 実行:
```bash
./komari server -l 0.0.0.0:25774
```
デフォルトのリスニングポートは `25774` です。`http://localhost:25774` にアクセスします。
## フロントエンド開発ガイド
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## クライアントエージェント開発ガイド
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
## 貢献
IssueやPull Requestを歓迎します!
## 謝辞
- こんなに暇でいられる自分に感謝
- PRを提出し、テーマを作成してくれたすべての開発者
## Star履歴
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+118
View File
@@ -0,0 +1,118 @@
# 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)
![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)
Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單、高效的伺服器性能監控解決方案。它支援透過 Web 介面查看伺服器狀態,並透過輕量級 Agent 收集數據。
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
## 特性
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
- **自託管**:完全掌控數據隱私,部署簡單。
- **Web 介面**:直觀的監控儀表盤,易於使用。
## 快速開始
### 0. 容器雲一鍵部署
- 雨雲雲應用 - CNY 4.5/月
[![](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
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 建立資料目錄:
```bash
mkdir -p ./data
```
2. 執行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看預設帳號和密碼:
```bash
docker logs komari
```
4. 在瀏覽器中存取 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
### 3. 二進位檔案部署
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
2. 執行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在瀏覽器中存取 `http://<your_server_ip>:25774`,預設監聽 `25774` 連接埠。
4. 預設帳號和密碼可在啟動日誌中查看,或透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 設定。
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
### 手工建置
#### 依賴
- Go 1.18+ 和 Node.js 20+(手工建置)
1. 建置前端靜態檔案:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 建置後端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
```bash
go build -o komari
```
4. 執行:
```bash
./komari server -l 0.0.0.0:25774
```
預設監聽 `25774` 連接埠,存取 `http://localhost:25774`。
## 前端開發指南
[Komari 主題開發指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻譯 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客戶端 Agent 開發指南
[Komari Agent 資訊上報與事件處理文檔](https://komari-document.pages.dev/dev/agent.html)
## 貢獻
歡迎提交 Issue 或 Pull Request!
## 鳴謝
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感謝我自己能這麼閒
- 提交PR、製作主題的各位開發者
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+117
View File
@@ -0,0 +1,117 @@
# 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)
![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)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
## 快速开始
### 0. 容器云一键部署
- 雨云云应用 - CNY 4.5/月
[![](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
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
```
2. 运行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看默认账号和密码:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
### 手工构建
#### 依赖
- Go 1.18+ 和 Node.js 20+(手工构建)
1. 构建前端静态文件:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 构建后端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
```bash
go build -o komari
```
4. 运行:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
## 前端开发指南
[Komari 主题开发指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻译 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-document.pages.dev/dev/agent.html)
## 贡献
欢迎提交 Issue 或 Pull Request!
## 鸣谢
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感谢我自己能这么闲
- 提交PR、制作主题的各位开发者
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/oschwald/maxminddb-golang v1.13.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
+2
View File
@@ -74,6 +74,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+415
View File
@@ -0,0 +1,415 @@
#!/bin/bash
# Color definitions for terminal output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "$1"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
log_step() {
echo -e "${YELLOW}$1${NC}"
}
# Global variables
INSTALL_DIR="/opt/komari"
DATA_DIR="/opt/komari"
SERVICE_NAME="komari"
BINARY_PATH="$INSTALL_DIR/komari"
DEFAULT_PORT="25774"
LISTEN_PORT=""
# Show banner
show_banner() {
clear
echo "=============================================================="
echo " Komari Monitoring System Installer"
echo " https://github.com/komari-monitor/komari"
echo "=============================================================="
echo
}
# Check if running as root
check_root() {
if [ "$EUID" -ne 0 ]; then
log_error "请使用 root 权限运行此脚本"
exit 1
fi
}
# Check for systemd
check_systemd() {
if ! command -v systemctl >/dev/null 2>&1; then
return 1
else
return 0
fi
}
# Detect system architecture
detect_arch() {
local arch=$(uname -m)
case $arch in
x86_64)
echo "amd64"
;;
aarch64)
echo "arm64"
;;
i386|i686)
echo "386"
;;
riscv64)
echo "riscv64"
;;
*)
log_error "不支持的架构: $arch"
exit 1
;;
esac
}
# Check if Komari is already installed
is_installed() {
if [ -f "$BINARY_PATH" ]; then
return 0 # 0 means true in bash exit codes
else
return 1 # 1 means false
fi
}
# Install dependencies
install_dependencies() {
log_step "检查并安装依赖..."
if ! command -v curl >/dev/null 2>&1; then
if command -v apt >/dev/null 2>&1; then
log_info "使用 apt 安装依赖..."
apt update
apt install -y curl
elif command -v yum >/dev/null 2>&1; then
log_info "使用 yum 安装依赖..."
yum install -y curl
elif command -v apk >/dev/null 2>&1; then
log_info "使用 apk 安装依赖..."
apk add curl
else
log_error "未找到支持的包管理器 (apt/yum/apk)"
exit 1
fi
fi
}
# Binary installation
install_binary() {
log_step "开始二进制安装..."
if is_installed; then
log_info "Komari 已安装。要升级,请使用升级选项。"
return
fi
# 监听端口输入,校验范围 1-65535
while true; do
read -p "请输入监听端口 [默认: $DEFAULT_PORT]: " input_port
if [[ -z "$input_port" ]]; then
LISTEN_PORT="$DEFAULT_PORT"
break
elif [[ "$input_port" =~ ^[0-9]+$ ]] && (( input_port >= 1 && input_port <= 65535 )); then
LISTEN_PORT="$input_port"
break
else
log_error "端口号无效,请输入 1-65535 之间的数字。"
fi
done
install_dependencies
local arch=$(detect_arch)
log_info "检测到架构: $arch"
log_step "创建安装目录: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
log_step "创建数据目录: $DATA_DIR"
mkdir -p "$DATA_DIR"
local file_name="komari-linux-${arch}"
local download_url="https://github.com/komari-monitor/komari/releases/latest/download/${file_name}"
log_step "下载 Komari 二进制文件..."
log_info "URL: $download_url"
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
log_error "下载失败"
return 1
fi
chmod +x "$BINARY_PATH"
log_success "Komari 二进制文件安装完成: $BINARY_PATH"
if ! check_systemd; then
log_step "警告:未检测到 systemd,跳过服务创建。"
log_step "您可以从命令行手动运行 Komari:"
log_step " $BINARY_PATH server -l 0.0.0.0:$LISTEN_PORT"
echo
log_success "安装完成!"
return
fi
create_systemd_service "$LISTEN_PORT"
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}.service
systemctl start ${SERVICE_NAME}.service
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
log_success "Komari 服务启动成功"
log_step "正在获取初始密码..."
sleep 5
local password=$(journalctl -u ${SERVICE_NAME} --since "1 minute ago" | grep "admin account created." | tail -n 1 | sed -e 's/.*admin account created.//')
if [ -z "$password" ]; then
log_error "未能获取初始密码,请检查日志"
fi
show_access_info "$password" "$LISTEN_PORT"
else
log_error "Komari 服务启动失败"
log_info "查看日志: journalctl -u ${SERVICE_NAME} -f"
return 1
fi
}
# Create systemd service file
create_systemd_service() {
local port="$1"
log_step "创建 systemd 服务..."
local service_file="/etc/systemd/system/${SERVICE_NAME}.service"
cat > "$service_file" << EOF
[Unit]
Description=Komari Monitor Service
After=network.target
[Service]
Type=simple
ExecStart=${BINARY_PATH} server -l 0.0.0.0:${port}
WorkingDirectory=${DATA_DIR}
Restart=always
User=root
[Install]
WantedBy=multi-user.target
EOF
log_success "systemd 服务文件创建完成"
}
# Show access information
show_access_info() {
local password=$1
local port=${2:-$DEFAULT_PORT}
echo
log_success "安装完成!"
echo
log_info "访问信息:"
log_info " URL: http://$(hostname -I | awk '{print $1}'):${port}"
if [ -n "$password" ]; then
log_info "初始登录信息(仅显示一次): $password"
fi
echo
log_info "服务管理命令:"
log_info " 状态: systemctl status $SERVICE_NAME"
log_info " 启动: systemctl start $SERVICE_NAME"
log_info " 停止: systemctl stop $SERVICE_NAME"
log_info " 重启: systemctl restart $SERVICE_NAME"
log_info " 日志: journalctl -u $SERVICE_NAME -f"
}
# Upgrade function
upgrade_komari() {
log_step "升级 Komari..."
if ! is_installed; then
log_error "Komari 未安装。请先安装它。"
return 1
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法管理服务。"
return 1
fi
log_step "停止 Komari 服务..."
systemctl stop ${SERVICE_NAME}.service
log_step "备份当前二进制文件..."
cp "$BINARY_PATH" "${BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
local arch=$(detect_arch)
local file_name="komari-linux-${arch}"
local download_url="https://github.com/komari-monitor/komari/releases/latest/download/${file_name}"
log_step "下载最新版本..."
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
log_error "下载失败,正在从备份恢复"
mv "${BINARY_PATH}.backup."* "$BINARY_PATH"
systemctl start ${SERVICE_NAME}.service
return 1
fi
chmod +x "$BINARY_PATH"
log_step "重启 Komari 服务..."
systemctl start ${SERVICE_NAME}.service
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
log_success "Komari 升级成功"
else
log_error "服务在升级后未能启动"
fi
}
# Uninstall function
uninstall_komari() {
log_step "卸载 Komari..."
if ! is_installed; then
log_info "Komari 未安装"
return 0
fi
read -p "这将删除 Komari。您确定吗?(Y/n): " confirm
if [[ $confirm =~ ^[Nn]$ ]]; then
log_info "卸载已取消"
return 0
fi
if check_systemd; then
log_step "停止并禁用服务..."
systemctl stop ${SERVICE_NAME}.service >/dev/null 2>&1
systemctl disable ${SERVICE_NAME}.service >/dev/null 2>&1
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
log_success "systemd 服务已删除"
fi
log_step "删除二进制文件..."
rm -f "$BINARY_PATH"
# 尝试在目录为空时删除该目录
rmdir "$INSTALL_DIR" 2>/dev/null || log_info "数据目录 $INSTALL_DIR 不为空,未删除"
log_success "Komari 二进制文件已删除"
log_success "Komari 卸载完成"
log_info "数据文件保留在 $DATA_DIR"
}
# Show service status
show_status() {
if ! is_installed; then
log_error "Komari 未安装"
return
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法获取服务状态。"
return
fi
log_step "Komari 服务状态:"
systemctl status ${SERVICE_NAME}.service --no-pager -l
}
# Show service logs
show_logs() {
if ! is_installed; then
log_error "Komari 未安装"
return
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法获取服务日志。"
return
fi
log_step "查看 Komari 服务日志..."
journalctl -u ${SERVICE_NAME} -f --no-pager
}
# Restart service
restart_service() {
if ! is_installed; then
log_error "Komari 未安装"
return
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法重启服务。"
return
fi
log_step "重启 Komari 服务..."
systemctl restart ${SERVICE_NAME}.service
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
log_success "服务重启成功"
else
log_error "服务重启失败"
fi
}
# Stop service
stop_service() {
if ! is_installed; then
log_error "Komari 未安装"
return
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法停止服务。"
return
fi
log_step "停止 Komari 服务..."
systemctl stop ${SERVICE_NAME}.service
log_success "服务已停止"
}
# Main menu
main_menu() {
show_banner
echo "请选择操作:"
echo " 1) 安装 Komari"
echo " 2) 升级 Komari"
echo " 3) 卸载 Komari"
echo " 4) 查看状态"
echo " 5) 查看日志"
echo " 6) 重启服务"
echo " 7) 停止服务"
echo " 8) 退出"
echo
read -p "输入选项 [1-8]: " choice
case $choice in
1) install_binary ;;
2) upgrade_komari ;;
3) uninstall_komari ;;
4) show_status ;;
5) show_logs ;;
6) restart_service ;;
7) stop_service ;;
8) exit 0 ;;
*) log_error "无效选项" ;;
esac
}
# Main execution
check_root
main_menu
+214 -25
View File
@@ -9,10 +9,12 @@ import (
"io/fs"
"log"
"net/http"
"os" // added for file existence check
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
)
@@ -25,6 +27,11 @@ var RawIndexFile string
var IndexFile string
func initIndex() {
err := os.MkdirAll("./data/theme", 0755)
if err != nil {
log.Println("Failed to create theme directory:", err)
return
}
dist, err := fs.Sub(PublicFS, "dist")
if err != nil {
log.Println("Failed to create dist subdirectory:", err)
@@ -48,6 +55,11 @@ func initIndex() {
RawIndexFile = string(index)
}
func UpdateIndex(cfg models.Config) {
IndexFile = applyCustomizations(RawIndexFile, cfg)
}
// applyCustomizations 应用自定义内容到HTML字符串
func applyCustomizations(htmlContent string, cfg models.Config) string {
var titleReplacement string
if cfg.Sitename == "Komari" {
titleReplacement = "<title>Komari Monitor</title>"
@@ -61,30 +73,17 @@ func UpdateIndex(cfg models.Config) {
"</head>": cfg.CustomHead + "</head>",
"</body>": cfg.CustomBody + "</body>",
}
updated := RawIndexFile
updated := htmlContent
for k, v := range replaceMap {
updated = strings.Replace(updated, k, v, -1)
}
IndexFile = updated
return updated
}
func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
initIndex()
folders := []string{"assets", "images", "streamer", "static"}
r.Use(func(c *gin.Context) {
for i := range folders {
if strings.HasPrefix(c.Request.RequestURI, fmt.Sprintf("/%s/", folders[i])) {
c.Header("Cache-Control", "public, max-age=15552000")
}
}
})
for i, folder := range folders {
sub, err := fs.Sub(DistFS, folder)
if err != nil {
log.Fatalf("can't find folder: %s", folder)
}
r.StaticFS(fmt.Sprintf("/%s/", folders[i]), http.FS(sub))
}
// Serve favicon.ico: use local file if exists, fallback to embedded
r.GET("/favicon.ico", func(c *gin.Context) {
if _, err := os.Stat("data/favicon.ico"); err == nil {
@@ -104,17 +103,207 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
}
c.Data(http.StatusOK, "image/x-icon", data)
})
// r.GET("/manifest.json", func(c *gin.Context) {
// cfg, err := config.Get()
// if cfg.Theme == "default" || cfg.Theme == "" || err != nil {
// // 使用默认主题的manifest.json
// f, err := DistFS.Open("manifest.json")
// if err != nil {
// c.Status(http.StatusNotFound)
// return
// }
// defer f.Close()
// data, err := io.ReadAll(f)
// if err != nil {
// c.Status(http.StatusInternalServerError)
// return
// }
// c.Data(http.StatusOK, "application/json", data)
// } else {
// // 使用自定义主题的manifest.json
// themePath := filepath.Join("./data/theme", cfg.Theme, "dist", "manifest.json")
// if _, err := os.Stat(themePath); os.IsNotExist(err) {
// c.JSON(http.StatusNotFound, gin.H{"error": "Manifest not found"})
// } else {
// c.File(themePath)
// }
// }
// })
// Serve theme files from data/theme directory (for theme previews and static assets)
r.Static("/themes", "./data/theme")
// 使用传入的noRoute函数来处理未匹配的路由
noRoute(func(c *gin.Context) {
if c.Request.Method != "GET" && c.Request.Method != "POST" {
c.Status(405)
path := c.Request.URL.Path
// 对于admin页面,直接使用embedded文件
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
serveFromEmbedded(c, path)
return
}
c.Header("Content-Type", "text/html")
c.Status(200)
c.Writer.WriteString(IndexFile)
c.Writer.Flush()
c.Writer.WriteHeaderNow()
// 获取当前主题配置
cfg, err := config.Get()
if err != nil || cfg.Theme == "default" || cfg.Theme == "" {
// 使用默认主题(embedded文件)
serveFromEmbedded(c, path)
return
}
// 使用自定义主题
serveFromTheme(c, path, cfg.Theme)
})
}
// serveFromEmbedded 从嵌入的文件系统服务文件
func serveFromEmbedded(c *gin.Context, path string) {
// 处理静态资源文件夹
// folders := []string{"assets", "images", "streamer", "static"}
// for _, folder := range folders {
// if strings.HasPrefix(path, "/"+folder+"/") {
// c.Header("Cache-Control", "public, max-age=15552000")
// sub, err := fs.Sub(DistFS, folder)
// if err == nil {
// relativePath := strings.TrimPrefix(path, "/"+folder+"/")
// file, err := sub.Open(relativePath)
// if err == nil {
// defer file.Close()
// data, err := io.ReadAll(file)
// if err == nil {
// // 设置正确的Content-Type
// contentType := getContentType(path)
// c.Header("Content-Type", contentType)
// c.Data(http.StatusOK, contentType, data)
// return
// }
// }
// }
// }
// }
// 如果文件存在,直接返回文件内容
cleanPath := strings.TrimPrefix(path, "/")
file, err := DistFS.Open(cleanPath)
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
contentType := getContentType(path)
c.Header("Content-Type", contentType)
// 静态资源设置缓存
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
c.Header("Cache-Control", "public, max-age=15552000")
}
c.Data(http.StatusOK, contentType, data)
return
}
}
// 处理HTML页面
if c.Request.Method != "GET" && c.Request.Method != "POST" {
c.Status(405)
return
}
c.Header("Content-Type", "text/html")
c.Status(200)
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
c.Writer.WriteString(RawIndexFile)
} else {
c.Writer.WriteString(IndexFile)
}
c.Writer.Flush()
c.Writer.WriteHeaderNow()
}
// serveFromTheme 从主题目录服务文件
func serveFromTheme(c *gin.Context, path string, themeName string) {
themeDir := filepath.Join("./data/theme", themeName, "dist")
// 构建完整的文件路径
var filePath string
if path == "/" || path == "" {
filePath = filepath.Join(themeDir, "index.html")
} else {
filePath = filepath.Join(themeDir, strings.TrimPrefix(path, "/"))
}
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// 如果文件不存在,尝试serve index.html(用于SPA路由)
if !strings.Contains(path, ".") {
indexPath := filepath.Join(themeDir, "index.html")
if _, err := os.Stat(indexPath); err == nil {
serveThemeIndexWithCustomizations(c, indexPath)
return
}
}
// 回退到默认主题
serveFromEmbedded(c, path)
return
}
// 设置缓存头
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
c.Header("Cache-Control", "public, max-age=15552000")
}
// 如果是index.html文件,需要处理自定义内容
if strings.HasSuffix(filePath, "index.html") {
serveThemeIndexWithCustomizations(c, filePath)
return
}
c.File(filePath)
}
// serveThemeIndexWithCustomizations 服务主题的index.html并应用自定义内容
func serveThemeIndexWithCustomizations(c *gin.Context, indexPath string) {
// 读取主题的index.html文件
data, err := os.ReadFile(indexPath)
if err != nil {
c.Status(http.StatusNotFound)
return
}
// 获取配置以应用自定义内容
cfg, err := config.Get()
if err != nil {
// 如果获取配置失败,直接返回原始文件
c.Header("Content-Type", "text/html")
c.Data(http.StatusOK, "text/html", data)
return
}
// 使用通用的自定义内容应用函数
content := applyCustomizations(string(data), cfg)
c.Header("Content-Type", "text/html")
c.Data(http.StatusOK, "text/html", []byte(content))
}
// getContentType 根据文件扩展名返回Content-Type
func getContentType(path string) string {
if strings.HasSuffix(path, ".css") {
return "text/css"
} else if strings.HasSuffix(path, ".js") {
return "application/javascript"
} else if strings.HasSuffix(path, ".png") {
return "image/png"
} else if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
return "image/jpeg"
} else if strings.HasSuffix(path, ".gif") {
return "image/gif"
} else if strings.HasSuffix(path, ".svg") {
return "image/svg+xml"
} else if strings.HasSuffix(path, ".ico") {
return "image/x-icon"
} else if strings.HasSuffix(path, ".woff") {
return "font/woff"
} else if strings.HasSuffix(path, ".woff2") {
return "font/woff2"
}
return "application/octet-stream"
}
+61
View File
@@ -0,0 +1,61 @@
# Frontend Build Instructions / 前端构建说明 / フロントエンド構築手順
## English
### Frontend Repository
- **Frontend project repository**: https://github.com/komari-monitor/komari-web
### Build Requirements
1. Clone the frontend repository and build the static files
2. Copy the generated static files to the `dist` folder in the Komari backend project root directory
3. Ensure the `dist` folder contains `index.html`
### Important Note
⚠️ **The projects under Akizon77's personal repository are no longer maintained. Please use the projects under the organization (komari-monitor).**
---
## 中文
### 前端项目仓库
- **前端项目地址**: https://github.com/komari-monitor/komari-web
### 构建要求
1. 克隆前端仓库并构建静态文件
2. 将生成的静态文件复制到 Komari 后端项目根目录下的 `dist` 文件夹
3. 确保 `dist` 文件夹内包含 `index.html`
### 重要提醒
⚠️ **Akizon77 个人仓库的项目已经不再使用,请使用组织(komari-monitor)下的项目。**
---
## 日本語
### フロントエンドプロジェクトリポジトリ
- **フロントエンドプロジェクトアドレス**: https://github.com/komari-monitor/komari-web
### ビルド要件
1. フロントエンドリポジトリをクローンして静的ファイルをビルドする
2. 生成された静的ファイルを Komari バックエンドプロジェクトのルートディレクトリ下の `dist` フォルダーにコピーする
3. `dist` フォルダー内に `index.html` が含まれていることを確認する
### 重要な注意事項
⚠️ **Akizon77 の個人リポジトリのプロジェクトは使用されなくなりました。組織(komari-monitor)下のプロジェクトを使用してください。**
---
## Quick Setup / 快速设置 / クイックセットアップ
```bash
# Clone frontend repository / 克隆前端仓库 / フロントエンドリポジトリをクローン
git clone https://github.com/komari-monitor/komari-web
cd komari-web
# Install dependencies and build / 安装依赖并构建 / 依存関係をインストールしてビルド
npm install
npm run build
# Copy dist folder to Komari backend project / 将 dist 文件夹复制到 Komari 后端项目 / dist フォルダーを Komari バックエンドプロジェクトにコピー
cp -r dist /path/to/komari/public/
```
-39
View File
@@ -1,39 +0,0 @@
# komari 待办清单
## 通用
- [ ] 低带宽模式
- [x] Web终端
- [x] 一键部署脚本
- [x] 远程命令执行
## Serser
- [x] Docker 部署
- [ ] Web前端
- [x] Web前端嵌入二进制
- [x] 命令行指令 chpasswd 强制更换密码
- [x] Github单点登录、绑定、解绑
- [x] 2FA
- [ ] 负载通知
- [x] 离线通知
- [ ] DDNS
- [x] Agent 备注
## Agent
- [x] 自动更新
- [x] 慢速模式(调整interval)
+149
View File
@@ -0,0 +1,149 @@
package cloudflared
import (
"bufio"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"runtime"
)
func downloadCloudflared(downloadURL, filePath string) error {
log.Println("downloading cloudflared...")
resp, err := http.Get(downloadURL)
if err != nil {
return err
}
defer resp.Body.Close()
out, err := os.Create(filePath)
if err != nil {
return err
}
_, err = io.Copy(out, resp.Body)
closeErr := out.Close() // 先关闭文件
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if runtime.GOOS != "windows" {
err = os.Chmod(filePath, 0755)
if err != nil {
return err
}
}
return nil
}
var cloudflaredCmd *exec.Cmd
func RunCloudflared() error {
var (
downloadURL string
fileName = "cloudflared"
)
if _, err := os.Stat("data"); os.IsNotExist(err) {
os.Mkdir("data", 0755)
}
switch runtime.GOOS {
case "windows":
fileName = "cloudflared.exe"
switch runtime.GOARCH {
case "amd64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
case "386":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-386.exe"
default:
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
}
case "linux":
switch runtime.GOARCH {
case "amd64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64"
case "386":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-386"
case "arm":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm"
case "arm64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64"
default:
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
}
case "darwin":
switch runtime.GOARCH {
case "amd64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
case "arm64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz"
default:
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
}
default:
return fmt.Errorf("unsupported os: %s", runtime.GOOS)
}
filePath := "data/" + fileName
if _, err := os.Stat(filePath); os.IsNotExist(err) {
err := downloadCloudflared(downloadURL, filePath)
if err != nil {
return err
}
}
args := []string{"tunnel", "--no-autoupdate", "run", "--token"}
token := os.Getenv("KOMARI_CLOUDFLARED_TOKEN")
args = append(args, token)
cmd := exec.Command(filePath, args...)
cloudflaredCmd = cmd
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
log.Printf("[cloudflared] %s", scanner.Text())
}
}()
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
log.Printf("[cloudflared] %s", scanner.Text())
}
}()
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("cloudflared exited with error: %v", err)
} else {
log.Println("cloudflared exited successfully")
}
os.Exit(1)
}()
log.Println("cloudflared started")
return nil
}
func Kill() {
if cloudflaredCmd != nil && cloudflaredCmd.Process != nil {
err := cloudflaredCmd.Process.Kill()
if err != nil {
log.Printf("failed to kill cloudflared: %v", err)
} else {
log.Println("cloudflared killed")
}
cloudflaredCmd = nil
}
}
+21
View File
@@ -0,0 +1,21 @@
package cloudflared_test
import (
"os"
"testing"
"time"
"github.com/komari-monitor/komari/utils/cloudflared"
)
func TestRunCloudflared(t *testing.T) {
os.Setenv("KOMARI_CLOUDFLARED_TOKEN", "test-token")
err := cloudflared.RunCloudflared()
if err != nil {
t.Fatalf("RunCloudflared failed: %v", err)
}
// 等待一段时间,确保子进程已启动
time.Sleep(2 * time.Second)
}
+25
View File
@@ -0,0 +1,25 @@
package geoip
import (
"fmt"
"net"
)
type EmptyProvider struct{}
func (e *EmptyProvider) Name() string {
return "EmptyProvider"
}
func (e *EmptyProvider) Initialize() error {
return nil
}
func (e *EmptyProvider) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
return nil, fmt.Errorf("you are using an empty GeoIP provider, please set a valid provider")
}
func (e *EmptyProvider) UpdateDatabase() error {
return fmt.Errorf("you are using an empty GeoIP provider, please set a valid provider")
}
func (e *EmptyProvider) Close() error {
return nil
}
+107 -81
View File
@@ -1,100 +1,39 @@
package geoip
import (
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"unicode"
"github.com/oschwald/maxminddb-golang"
"github.com/komari-monitor/komari/database/config"
"github.com/patrickmn/go-cache"
)
var (
GeoIpUrl = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
GeoIpFilePath = "./data/GeoLite2-Country.mmdb"
geoIpDb *maxminddb.Reader = nil
lock = &sync.RWMutex{}
)
var CurrentProvider GeoIPService
var geoCache *cache.Cache
type GeoIpRecord struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
type GeoInfo struct {
ISOCode string
Name string
}
// 更新Geoip数据库,使用 GeoIpUrl下载最新的数据库文件,并覆盖本地的 GeoIpFilePath 文件
func UpdateGeoIpDatabase() error {
lock.Lock()
defer lock.Unlock()
if geoIpDb != nil {
geoIpDb.Close()
geoIpDb = nil
}
log.Println("Downloading GeoIP database...")
resp, err := http.Get(GeoIpUrl)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download file: %s", resp.Status)
}
out, err := os.Create(GeoIpFilePath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
return err
}
return nil
func init() {
CurrentProvider = &EmptyProvider{}
geoCache = cache.New(48*time.Hour, 1*time.Hour)
}
func InitGeoIp() {
if os.MkdirAll("./data", os.ModePerm) != nil {
return
}
if _, err := os.Stat(GeoIpFilePath); os.IsNotExist(err) {
err := UpdateGeoIpDatabase()
if err != nil {
log.Println("Error updating GeoIP database:", err)
} else {
log.Println("GeoIP database updated successfully.")
}
}
var err error
geoIpDb, err = maxminddb.Open(GeoIpFilePath)
if err != nil {
log.Printf("Error opening GeoIP database: %v", err)
}
}
// GeoIPService 接口定义了获取地理位置信息的核心方法。
// 任何实现此接口的类型都可以作为地理位置服务提供者。
type GeoIPService interface {
Name() string
func GetGeoIpInfo(ip net.IP) (*GeoIpRecord, error) {
lock.RLock()
defer lock.RUnlock()
if geoIpDb == nil {
InitGeoIp()
}
if ip == nil {
return nil, fmt.Errorf("IP address is nil")
}
var record GeoIpRecord
err := geoIpDb.Lookup(ip, &record)
if err != nil {
log.Printf("Error looking up IP %s: %v", ip.String(), err)
return nil, err
}
return &record, nil
GetGeoInfo(ip net.IP) (*GeoInfo, error)
UpdateDatabase() error
Close() error
}
func GetRegionUnicodeEmoji(isoCode string) string {
@@ -111,3 +50,90 @@ func GetRegionUnicodeEmoji(isoCode string) string {
rune2 := rune(0x1F1E6 + (rune(isoCode[1]) - 'A'))
return string(rune1) + string(rune2)
}
func InitGeoIp() {
conf, err := config.Get()
if err != nil {
panic("Failed to get configuration for GeoIP: " + err.Error())
}
if !conf.GeoIpEnabled {
return
}
switch conf.GeoIpProvider {
case "mmdb":
NewCurrentProvider, err := NewMaxMindGeoIPService()
if err != nil {
log.Printf("Failed to initialize MaxMind GeoIP service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize MaxMind GeoIP service, using EmptyProvider instead.")
}
case "ip-api":
NewCurrentProvider, err := NewIPAPIService()
if err != nil {
log.Printf("Failed to initialize ip-api service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using ip-api.com as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize ip-api service, using EmptyProvider instead.")
}
case "geojs":
NewCurrentProvider, err := NewGeoJSService()
if err != nil {
log.Printf("Failed to initialize GeoJS service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using geojs.io as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize GeoJS service, using EmptyProvider instead.")
}
case "ipinfo":
NewCurrentProvider, err := NewIPInfoService()
if err != nil {
log.Printf("Failed to initialize IPInfo service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using ipinfo.io as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize IPInfo service, using EmptyProvider instead.")
}
default:
CurrentProvider = &EmptyProvider{}
}
}
func GetGeoInfo(ip net.IP) (*GeoInfo, error) {
providerName := CurrentProvider.Name()
cacheKey := providerName + ":" + ip.String()
if cachedInfo, found := geoCache.Get(cacheKey); found {
//log.Println("GeoIP cache hit for", cacheKey)
return cachedInfo.(*GeoInfo), nil
}
info, err := CurrentProvider.GetGeoInfo(ip)
if err == nil && info != nil {
//log.Println("GeoIP cache miss for", cacheKey)
geoCache.Set(cacheKey, info, cache.DefaultExpiration)
}
return info, err
}
func UpdateDatabase() error {
err := CurrentProvider.UpdateDatabase()
if err == nil {
geoCache.Flush()
log.Println("GeoIP cache cleared due to database update.")
}
return err
}
+23 -17
View File
@@ -2,58 +2,64 @@ package geoip_test
import (
"net"
"os"
"testing"
"github.com/komari-monitor/komari/utils/geoip"
)
// 测试GeoIP数据库的初始化和更新功能
func TestInitGeoIp(t *testing.T) {
geoip.InitGeoIp()
func TestMmdb(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewMaxMindGeoIPService()
testIpAddr(t)
}
func TestIpApi(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewIPAPIService()
testIpAddr(t)
}
// 检查数据库
fileInfo, err := os.Stat(geoip.GeoIpFilePath)
if err != nil {
t.Errorf("Failed to get file info: %v", err)
}
if fileInfo.Size() == 0 {
t.Errorf("GeoIP database file is empty: %s", geoip.GeoIpFilePath)
}
func TestGeojs(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewGeoJSService()
testIpAddr(t)
}
func TestIpInfo(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewIPInfoService()
testIpAddr(t)
}
func testIpAddr(t *testing.T) {
// IPv4
ipaddr := "8.8.8.8"
ip := net.ParseIP(ipaddr)
record, err := geoip.GetGeoIpInfo(ip)
record, err := geoip.GetGeoInfo(ip)
if err != nil {
t.Errorf("Failed to get GeoIP info for IP %s: %v", ipaddr, err)
}
if record != nil {
if record.Country.ISOCode == "" && record.Country.Names["zh-CN"] == "" {
if record.ISOCode == "" && record.Name == "" {
t.Errorf("Country information is missing for IP %s", ipaddr)
}
} else {
t.Errorf("GeoIP record is nil for IP %s", ipaddr)
}
t.Logf("IPv4:[%s]%s - %s", ipaddr, record.Country.ISOCode, record.Country.Names["zh-CN"])
t.Logf("IPv4:[%s]%s - %s", ipaddr, record.ISOCode, record.Name)
// IPv6
ipaddr = "2001:4860:4860::8888"
ip = net.ParseIP(ipaddr)
record, err = geoip.GetGeoIpInfo(ip)
record, err = geoip.GetGeoInfo(ip)
if err != nil {
t.Errorf("Failed to get GeoIP info for IPv6 %s: %v", ipaddr, err)
}
if record != nil {
if record.Country.ISOCode == "" && record.Country.Names["zh-CN"] == "" {
if record.ISOCode == "" && record.Name == "" {
t.Errorf("Country information is missing for IPv6 %s", ipaddr)
}
} else {
t.Errorf("GeoIP record is nil for IPv6 %s", ipaddr)
}
t.Logf("IPv6:[%s]%s - %s", ipaddr, record.Country.ISOCode, record.Country.Names["zh-CN"])
t.Logf("IPv6:[%s]%s - %s", ipaddr, record.ISOCode, record.Name)
}
func TestUnicodeEmoji(t *testing.T) {
+80
View File
@@ -0,0 +1,80 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// GeoJSService 使用 geojs.io 服务实现 GeoIPService 接口。
type GeoJSService struct {
Client *http.Client
}
// geoJSResponse 定义了 geojs.io 服务返回的 JSON 响应的结构。
// 我们只定义我们需要的字段。
type geoJSResponse struct {
Country string `json:"country"`
CountryCode string `json:"country_code"`
// 可以根据需要添加其他字段,例如:
// City string `json:"city"`
// Region string `json:"region"`
}
// NewGeoJSService 创建并返回一个 GeoJSService 的新实例。
func NewGeoJSService() (*GeoJSService, error) {
return &GeoJSService{
Client: &http.Client{
Timeout: 5 * time.Second, // 设置一个合理的超时时间
},
}, nil
}
// Name 返回服务的名称。
func (s *GeoJSService) Name() string {
return "geojs.io"
}
// GetGeoInfo 使用 geojs.io 服务检索给定 IP 地址的地理位置信息。
func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// GeoJS 的 API 端点
apiURL := fmt.Sprintf("https://get.geojs.io/v1/ip/geo/%s.json", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from geojs.io: %w", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("geojs.io returned non-200 status code: %d", resp.StatusCode)
}
var apiResp geoJSResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode geojs.io response: %w", err)
}
// 检查国家代码是否为空,因为 geojs 对无效/私有IP可能返回200 OK但内容为空
if apiResp.CountryCode == "" {
return nil, fmt.Errorf("geojs.io returned empty geo info for ip: %s", ip.String())
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
}, nil
}
// UpdateDatabase 对于 geojs.io 是一个空操作,因为它是一个 Web 服务。
func (s *GeoJSService) UpdateDatabase() error {
return nil
}
// Close 对于 geojs.io 是一个空操作。
func (s *GeoJSService) Close() error {
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// IPAPIService 使用 ip-api.com 服务实现 GeoIPService 接口。
type IPAPIService struct {
Client *http.Client
}
// ipAPIResponse 定义了 ip-api.com 服务返回的 JSON 响应的结构。
type ipAPIResponse struct {
Status string `json:"status"`
Message string `json:"message"` // 当 status 为 fail 时出现
Country string `json:"country"`
CountryCode string `json:"countryCode"`
Region string `json:"region"`
RegionName string `json:"regionName"`
City string `json:"city"`
Zip string `json:"zip"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Timezone string `json:"timezone"`
ISP string `json:"isp"`
Org string `json:"org"`
As string `json:"as"`
Query string `json:"query"`
}
func (s *IPAPIService) Name() string {
return "ip-api.com"
}
// NewIPAPIService 创建并返回一个 IPAPIService 的新实例。
func NewIPAPIService() (*IPAPIService, error) {
return &IPAPIService{
Client: &http.Client{
Timeout: 5 * time.Second, // 设置请求超时
},
}, nil
}
// GetGeoInfo 使用 ip-api.com 服务检索给定 IP 地址的地理位置信息。
func (s *IPAPIService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// API URL, 使用 fields 参数来仅请求需要的字段
apiURL := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,countryCode", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from ip-api.com: %w", err)
}
defer resp.Body.Close()
var apiResp ipAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode ip-api.com response: %w", err)
}
if apiResp.Status != "success" {
return nil, fmt.Errorf("ip-api.com returned an error: %s", apiResp.Message)
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
}, nil
}
// UpdateDatabase 对于 ip-api.com 是一个空操作,因为它是一个 Web 服务。
func (s *IPAPIService) UpdateDatabase() error {
// 无需执行任何操作,因为数据由外部服务提供
return nil
}
// Close 对于 ip-api.com 是一个空操作,因为没有需要关闭的持久连接。
func (s *IPAPIService) Close() error {
// 无需执行任何操作
return nil
}
+91
View File
@@ -0,0 +1,91 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// IPInfoService 使用 ipinfo.io 服务实现 GeoIPService 接口。
type IPInfoService struct {
Client *http.Client
// 每天 1000 次请求,限制由 IP 地址的所有人共享。
// APIToken string
}
// ipInfoResponse 定义了 ipinfo.io 服务返回的 JSON 响应的结构,只包含免费额度可用的字段。
type ipInfoResponse struct {
IP string `json:"ip"`
Hostname string `json:"hostname"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
CountryCode string `json:"countryCode"` // ipinfo.io 返回 "country" 的 ISO 代码,这里为了与 GeoInfo 保持一致,额外添加一个 CountryCode
Loc string `json:"loc"` // Latitude,Longitude
Org string `json:"org"`
Postal string `json:"postal"`
Timezone string `json:"timezone"`
}
// NewIPInfoService 创建并返回一个 IPInfoService 的新实例。
func NewIPInfoService() (*IPInfoService, error) {
return &IPInfoService{
Client: &http.Client{
Timeout: 5 * time.Second,
},
}, nil
}
// Name 返回服务的名称。
func (s *IPInfoService) Name() string {
return "ipinfo.io"
}
// GetGeoInfo 使用 ipinfo.io 服务检索给定 IP 地址的地理位置信息。
// 免费额度主要提供国家信息。
func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// IPinfo 免费额度不需要 API token 就可以查询基本的 IP 信息。
// API URL: https://ipinfo.io/json (查询自身IP) 或 https://ipinfo.io/YOUR_IP/json
apiURL := fmt.Sprintf("https://ipinfo.io/%s/json", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from ipinfo.io: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ipinfo.io returned non-200 status: %d %s", resp.StatusCode, resp.Status)
}
var apiResp ipInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode ipinfo.io response: %w", err)
}
// IPinfo 的 "country" 字段直接返回 ISO 2-letter code,例如 "US", "CN"
// 我们需要将 "country" 字段作为 ISOCode,并尝试获取其对应的国家名称。
// IPinfo 响应中通常不直接提供完整的国家名称,但我们可以通过 CountryCode 映射。
// 为了简化并符合 GeoInfo 结构,我们直接使用 Country 作为 ISOCode,并尝试从 CountryCode 获取名称。
// 实际上,IPinfo 的 'country' 字段就是 ISO 2-letter code。
// 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。
// 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。
return &GeoInfo{
ISOCode: apiResp.Country, // IPinfo 的 'country' 字段就是 ISO 2-letter code
Name: apiResp.Country, // 免费额度通常只提供 ISO 编码,这里暂时用 ISO 编码作为名称
}, nil
}
// UpdateDatabase 对于 ipinfo.io 是一个空操作,因为它是一个 Web 服务。
func (s *IPInfoService) UpdateDatabase() error {
// 无需执行任何操作,因为数据由外部服务提供
return nil
}
// Close 对于 ipinfo.io 是一个空操作,因为没有需要关闭的持久连接。
func (s *IPInfoService) Close() error {
// 无需执行任何操作
return nil
}
+181
View File
@@ -0,0 +1,181 @@
package geoip // 与 geoip.go 保持相同的包名,表示它们是同一个包的组成部分
import (
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath" // 新增导入,用于处理文件路径
"sync"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/oschwald/maxminddb-golang"
)
// GeoIpUrl 是 MaxMind 数据库的下载地址。
var GeoIpUrl = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
// GeoIpFilePath 是本地存储 MaxMind 数据库的路径。
var GeoIpFilePath = "./data/GeoLite2-Country.mmdb"
// GeoIpRecord 结构体定义了 MaxMind 数据库查询结果的原始结构。
// 它是 MaxMind 库特有的,用于从 .mmdb 文件中解析数据。
type GeoIpRecord struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
}
// MaxMindGeoIPService 是 GeoIPService 接口的一个具体实现,
// 它使用 MaxMind 数据库作为后端。
type MaxMindGeoIPService struct {
// maxMindDBReader 内部持有的 MaxMind 数据库读取器实例。
maxMindDBReader *maxminddb.Reader
// dbFilePath 是 MaxMind 数据库文件的路径。
dbFilePath string
// mu 用于保护对 maxMindDBReader 的并发访问,确保线程安全。
mu sync.RWMutex
}
// Name 返回服务的名称。
func (s *MaxMindGeoIPService) Name() string {
return "MaxMind"
}
// NewMaxMindGeoIPService 创建并返回一个 MaxMindGeoIPService 实例。
// 它负责初始化服务,包括尝试加载或下载数据库。
func NewMaxMindGeoIPService() (*MaxMindGeoIPService, error) {
dbFilePath := GeoIpFilePath
service := &MaxMindGeoIPService{
dbFilePath: dbFilePath,
}
// 确保数据目录存在
if err := os.MkdirAll(filepath.Dir(dbFilePath), os.ModePerm); err != nil {
auditlog.Log("", "", "Failed to create data directory for MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to create data directory for MaxMind database: %w", err)
}
// 检查数据库文件是否存在,如果不存在则尝试下载
if _, err := os.Stat(dbFilePath); os.IsNotExist(err) {
if err := service.UpdateDatabase(); err != nil {
auditlog.Log("", "", "Failed to download initial MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to download initial MaxMind database: %w", err)
}
}
// 初始化或重新加载 MaxMind 数据库。
if err := service.initialize(); err != nil {
auditlog.Log("", "", "Failed to initialize MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to initialize MaxMind database: %w", err)
}
return service, nil
}
// initialize 初始化或重新加载 MaxMind 数据库。
// 这是一个内部方法,供 NewMaxMindGeoIPService 和 UpdateDatabase 调用。
// 它会关闭现有连接(如果存在)并重新打开数据库文件。
func (s *MaxMindGeoIPService) initialize() error {
s.mu.Lock()
defer s.mu.Unlock()
// 如果已存在数据库读取器,则先关闭它。
if s.maxMindDBReader != nil {
s.maxMindDBReader.Close()
s.maxMindDBReader = nil
}
// 尝试打开新的数据库文件。
reader, err := maxminddb.Open(s.dbFilePath)
if err != nil {
return fmt.Errorf("error opening MaxMind database at %s: %w", s.dbFilePath, err)
}
s.maxMindDBReader = reader
return nil
}
// GetGeoInfo 根据 IP 地址获取 MaxMind 的地理位置信息。
// 它查询 MaxMind 数据库并将其特有的 GeoIpRecord 转换为通用的 GeoInfo 结构体。
func (s *MaxMindGeoIPService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.maxMindDBReader == nil {
return nil, fmt.Errorf("MaxMind database is not initialized or failed to open")
}
if ip == nil {
return nil, fmt.Errorf("IP address cannot be nil")
}
var record GeoIpRecord // 使用原始的 GeoIpRecord 结构体来接收查询结果
err := s.maxMindDBReader.Lookup(ip, &record)
if err != nil {
// 返回错误,但避免直接返回 maxminddb 库的内部错误,提供更友好的信息
return nil, fmt.Errorf("error looking up IP %s in MaxMind database: %w", ip.String(), err)
}
// 将 MaxMind 的特定结构体转换为通用的 GeoInfo 结构体
geoInfo := &GeoInfo{
ISOCode: record.Country.ISOCode,
// 尝试获取英文国家名称,如果不存在则使用 ISO 代码作为备用
Name: record.Country.Names["en"],
}
if geoInfo.Name == "" && geoInfo.ISOCode != "" {
geoInfo.Name = geoInfo.ISOCode // 如果没有英文名称,回退到 ISO 代码
}
return geoInfo, nil
}
// UpdateDatabase 实现了 GeoIPService 接口的 UpdateDatabase 方法。
// 它会下载最新的 GeoLite2-Country.mmdb 文件并重新加载数据库。
func (s *MaxMindGeoIPService) UpdateDatabase() error {
s.mu.Lock() // 获取写锁,确保更新过程的互斥性
resp, err := http.Get(GeoIpUrl) // GeoIpUrl 是预定义的 MaxMind 数据库下载地址
if err != nil {
return fmt.Errorf("failed to initiate MaxMind database download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download MaxMind database: HTTP status %s", resp.Status)
}
// 确保数据目录存在(NewMaxMindGeoIPService 已处理,但这里再次确保以防直接调用)
if err := os.MkdirAll(filepath.Dir(s.dbFilePath), os.ModePerm); err != nil {
return fmt.Errorf("failed to create data directory for MaxMind database update: %w", err)
}
out, err := os.Create(s.dbFilePath) // 创建或覆盖本地数据库文件
if err != nil {
return fmt.Errorf("failed to create MaxMind database file at %s: %w", s.dbFilePath, err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body) // 将下载内容写入文件
if err != nil {
return fmt.Errorf("failed to write MaxMind database file: %w", err)
}
s.mu.Unlock() // initialize 方法需要在解锁后调用,以避免死锁
// 重新加载数据库以使用新下载的文件
return s.initialize()
}
// Close 实现了 GeoIPService 接口的 Close 方法。
// 它关闭 MaxMind 数据库读取器,释放文件句柄和其他资源。
func (s *MaxMindGeoIPService) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.maxMindDBReader != nil {
err := s.maxMindDBReader.Close()
s.maxMindDBReader = nil // 清空读取器实例
if err != nil {
return fmt.Errorf("error closing MaxMind database: %w", err)
}
}
log.Println("MaxMind GeoIP service closed.")
return nil
}
+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"
}
+41
View File
@@ -0,0 +1,41 @@
package item
import "reflect"
type Item struct {
Name string `json:"name"`
Required bool `json:"required"`
Type string `json:"type"`
Options string `json:"options"`
Default string `json:"default"`
Help string `json:"help"`
}
func Parse(v any) []Item {
val := reflect.ValueOf(v)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
var items []Item
for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i)
typ := field.Tag.Get("type")
if typ != "option" {
typ = field.Type.Name()
}
item := Item{
Name: field.Tag.Get("json"),
Required: field.Tag.Get("required") == "true",
Type: typ,
Options: field.Tag.Get("options"),
Default: field.Tag.Get("default"),
Help: field.Tag.Get("help"),
}
if item.Type == "" {
item.Type = "string"
}
items = append(items, item)
}
return items
}
+12
View File
@@ -0,0 +1,12 @@
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/telegram"
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
)
func All() {
}
+124
View File
@@ -0,0 +1,124 @@
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
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &BarkSender{}
})
}
// 确保实现了 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{}
})
}
+105
View File
@@ -0,0 +1,105 @@
package email
import (
"crypto/tls"
"fmt"
"net/smtp"
"strconv"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type EmailSender struct {
Addition
}
func (e *EmailSender) GetName() string {
return "email"
}
func (e *EmailSender) GetConfiguration() factory.Configuration {
return &e.Addition
}
func (e *EmailSender) Init() error {
return nil
}
func (e *EmailSender) Destroy() error {
return nil
}
func (e *EmailSender) SendTextMessage(message, title string) error {
if e.Addition.Host == "" || e.Addition.Sender == "" || e.Addition.Username == "" || e.Addition.Password == "" || e.Addition.Receiver == "" {
return fmt.Errorf("email sending is not fully configured")
}
auth := smtp.PlainAuth(
"",
e.Addition.Username,
e.Addition.Password,
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)
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()
if err = c.StartTLS(&tls.Config{ServerName: e.Addition.Host}); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
// Authenticate
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)
}
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)
}
return c.Quit()
} else {
// Send without SSL/TLS (less secure)
return smtp.SendMail(
addr,
auth,
e.Addition.Sender,
[]string{e.Addition.Receiver},
msg,
)
}
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*EmailSender)(nil)
+21
View File
@@ -0,0 +1,21 @@
package email
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
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"`
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &EmailSender{}
})
}
+43
View File
@@ -0,0 +1,43 @@
package empty
import (
"fmt"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
}
type EmptyProvider struct {
Addition
}
func (e *EmptyProvider) GetName() string {
return "empty"
}
func (e *EmptyProvider) GetConfiguration() factory.Configuration {
return &e.Addition
}
func (e *EmptyProvider) Init() error {
return nil
}
func (e *EmptyProvider) Destroy() error {
return nil
}
func (e *EmptyProvider) SendTextMessage(message, title string) error {
return fmt.Errorf("you are using an empty message sender, please check your configuration")
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &EmptyProvider{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*EmptyProvider)(nil)
+60
View File
@@ -0,0 +1,60 @@
package factory
import (
"log"
"github.com/komari-monitor/komari/utils/item"
)
var (
senders = make(map[string]IMessageSender)
senderConstructor = make(map[string]MessageSenderConstructor)
sendersAdditionalItems = make(map[string][]item.Item)
)
func RegisterMessageSender(constructor MessageSenderConstructor) {
sender := constructor()
senderConstructor[sender.GetName()] = constructor
if sender == nil {
panic("Message sender constructor returned nil")
}
if _, exists := senders[sender.GetName()]; exists {
log.Println("Message sender already registered: " + sender.GetName())
}
senders[sender.GetName()] = sender
// 使用反射来提取提供程序的配置字段
config := sender.GetConfiguration()
items := item.Parse(config)
sendersAdditionalItems[sender.GetName()] = items
}
func GetSenderConfigs() map[string][]item.Item {
return sendersAdditionalItems
}
func GetAllMessageSenders() map[string]IMessageSender {
return senders
}
func GetConstructor(name string) (MessageSenderConstructor, bool) {
constructor, exists := senderConstructor[name]
return constructor, exists
}
func GetAllMessageSenderNames() []string {
names := make([]string, 0, len(senders))
for name := range senders {
names = append(names, name)
}
return names
}
func Initialize() {
for _, sender := range senders {
if err := sender.Init(); err != nil {
log.Printf("Failed to initialize message sender %s: %v", sender.GetName(), err)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
package factory
type IMessageSender interface {
GetName() string
// 请务必返回 &Configuration{} 的指针
GetConfiguration() Configuration
SendTextMessage(message, title string) error
Init() error
Destroy() error
}
type Configuration interface{}
type MessageSenderConstructor func() IMessageSender
+52
View File
@@ -0,0 +1,52 @@
package messageSender
import (
"encoding/json"
"fmt"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func LoadProvider(name string, addition string) error {
mu.Lock()
defer mu.Unlock()
constructor, exists := factory.GetConstructor(name)
if !exists {
return fmt.Errorf("message sender provider not found: %s", name)
}
provider := constructor()
err := json.Unmarshal([]byte(addition), provider.GetConfiguration())
if err != nil {
return fmt.Errorf("failed to load config for provider %s: %w", name, err)
}
provider.Init()
if currentProvider != nil {
currentProvider.Destroy()
}
currentProvider = provider
return nil
}
func GetProviderConfiguration(name string) (map[string]interface{}, error) {
constructor, exists := factory.GetConstructor(name)
if !exists {
return nil, fmt.Errorf("message sender provider not found: %s", name)
}
provider := constructor()
config := provider.GetConfiguration()
// 将配置转换为map
configBytes, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("failed to marshal configuration: %w", err)
}
var configMap map[string]interface{}
if err := json.Unmarshal(configBytes, &configMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal configuration: %w", err)
}
return configMap, nil
}
+26
View File
@@ -0,0 +1,26 @@
package messageSender
import (
"testing"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func Test(t *testing.T) {
senders := factory.GetAllMessageSenders()
if len(senders) == 0 {
t.Error("No message senders found")
return
}
cfg := factory.GetSenderConfigs()
if len(cfg) == 0 {
t.Error("No sender configs found")
return
}
LoadProvider("email", `{"host":"smtp.example.com","port":587,"username":"user","password":"pass"}`)
cp := CurrentProvider
if cp() == nil {
t.Error("Current provider is nil")
return
}
}
+78 -9
View File
@@ -1,22 +1,91 @@
package messageSender
import (
"errors"
"encoding/json"
"fmt"
"log"
"sync"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func SendTextMessage(message string) error {
var (
currentProvider factory.IMessageSender
mu = sync.Mutex{}
once = sync.Once{}
)
func CurrentProvider() factory.IMessageSender {
mu.Lock()
defer mu.Unlock()
return currentProvider
}
func Initialize() {
go func() {
once.Do(func() {
all := factory.GetAllMessageSenders()
for _, provider := range all {
if _, err := database.GetMessageSenderConfigByName(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.SaveMessageSenderConfig(&models.MessageSenderProvider{
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.NotificationMethod == "" || cfg.NotificationMethod == "none" {
LoadProvider("empty", "{}")
return
}
// 尝试从数据库加载配置
senderConfig, err := database.GetMessageSenderConfigByName(cfg.NotificationMethod)
if err != nil {
// 如果没有找到配置,使用empty provider
LoadProvider("empty", "{}")
return
}
LoadProvider(cfg.NotificationMethod, senderConfig.Addition)
}
func SendTextMessage(message string, title string) 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
}
switch cfg.NotificationMethod {
case "none":
return nil
case "telegram":
return TelegramSendTextMessage(message)
default:
return errors.New("unsupported notification method: " + cfg.NotificationMethod)
for i := 0; i < 3; i++ {
err = CurrentProvider().SendTextMessage(message, title)
if err == nil {
auditlog.Log("", "", "Message sent: "+title, "info")
return nil
}
}
auditlog.Log("", "", "Failed to send message after 3 attempts: "+err.Error()+","+title, "error")
return err
}
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more