Compare commits

...
375 Commits
Author SHA1 Message Date
airium 618ced3b8f test(auth): remove obsolete dev cookie bypass coverage (#547) 2026-06-11 12:22:27 +08:00
Akizon77 f7fd459723 fix(auth): only set secure session cookie over HTTPS 2026-06-10 20:48:43 +08:00
Akizon77 bb22a818bf fix(client): fill GeoIP region for v2 basic info 2026-06-10 01:12:02 +08:00
Akizon77 b66fe47487 fix(config): rename CORS toggle to origin check setting
Replace the legacy allow_cors setting with cors_origin_check_enabled
throughout server setup, public info output, and configuration constants.
This clarifies that the option controls API CORS origin validation rather
than broadly enabling CORS.
2026-06-09 17:27:06 +08:00
Akizon77 98122fa4d1 ci: generate release notes from GitHub 2026-06-09 16:46:33 +08:00
Akizon77 c3f65b1a32 fix go tests 2026-06-08 21:14:27 +08:00
Akizon77 7da616ce4c feat(security): add configurable CORS and origin checks
Add settings for allowed CORS origins and WebSocket origin validation so
deployments can restrict cross-origin access instead of using a global
wildcard policy.

Move CORS handling into the security middleware and update report cache
usage to use the dedicated report package.
2026-06-08 20:45:00 +08:00
airium 9d914e70b9 fix: harden traffic report accounting and validation (#542)
- Store per-record traffic deltas and sum them for scheduled reports
- Preserve traffic delta totals during long-term record compaction
- Compact only complete 15-minute buckets to avoid partial-bucket undercounts
- Serialize report saves per client so concurrent uploads cannot double-count deltas
- Enforce enabled traffic report cadence validation across edit and enable paths
- Add regression coverage for compaction boundaries, cadence validation, and client save locking
2026-06-08 11:26:13 +08:00
Akizon77 6f0624e9b4 Merge branch 'main' into codex/theme-configuration-types 2026-06-08 01:58:52 +08:00
Akizon77 3cc67e11cd fix: align theme redirect and html language handling 2026-06-08 01:53:28 +08:00
Akizon77 742a5e953e feat: support raw and redirect themes 2026-06-08 01:53:28 +08:00
Akizon77 3b82cee4b1 fix: align theme redirect and html language handling 2026-06-08 01:53:20 +08:00
Akizon77 2b010954a3 feat: support raw and redirect themes 2026-06-08 00:48:37 +08:00
Akizon77 6f23565517 refactor: isolate startup migrations 2026-06-08 00:01:32 +08:00
airium 7c2896c3fe fix(remote-exec): preserve command text during validation (#543)
Reject whitespace-only remote exec commands without trimming the submitted
command body, so multiline scripts and meaningful leading/trailing content are
preserved through dispatch.
2026-06-07 23:54:43 +08:00
Akizon77 721d61bc4e docs: update default theme build path 2026-06-06 22:59:38 +08:00
kylvenp 014b236745 feat: add network test rpc protocol structs 2026-06-06 21:34:48 +08:00
Akizon77 80c8d7e5ee Merge pull request #537 from airium/fix-http-development 2026-06-06 20:05:30 +08:00
Akizon77 8423881cc4 Merge pull request #541 from airium/support-phy-cpu-count 2026-06-06 19:59:16 +08:00
Akizon77 a1d644977c Merge pull request #538 from airium/fix-theme-path 2026-06-06 19:59:05 +08:00
Akizon77 301fb536f2 Merge pull request #539 from aijwfsibari/main 2026-06-06 19:53:17 +08:00
airium 49a8d772c8 feat: support physical CPU core count 2026-06-06 19:15:03 +08:00
aijwfsibari a9adae29d2 add Traffic report patch 2026-06-05 15:04:21 +08:00
airium d6e23baa19 fix: correct defaultTheme path across CI and docs
The Go embed directive in web/public/public.go resolves defaultTheme
relative to the package directory (web/public/defaultTheme/),
but all CI workflows and documentation referenced public/defaultTheme/
which would have caused builds to fail or embed an empty directory.
2026-06-02 13:24:23 +08:00
airium 658e7603f8 fix: allow insecure session cookies in development build 2026-06-02 02:31:01 +08:00
Akizon77 fb0b9b9910 feat(agent): add v2 pull events for queued agent tasks
Introduce agent.pull protocol types with event metadata and ACK fields.
Route ping dispatch through the agent API so tasks can be queued for
pull-based clients instead of only sent over active WebSocket sessions.
2026-05-31 19:01:02 +08:00
Akizon77 e149e8b8b7 refactor: remove legacy client APIs and support v2 pings
Remove deprecated client database helpers tied to common protocol types and
update report handling to use protocol/v1 models. Send ping tasks with the
v2 JSON-RPC payload for v2 clients while preserving existing behavior for
legacy websocket clients.
2026-05-31 16:50:04 +08:00
Akizon77 d2cc1503de refactor: centralize scheduled jobs with corn package
Replace ad-hoc ticker loops with named cron-based scheduled tasks for cleanup,
minute reporting, and expiration checks. Add scheduler shutdown handling and
log errors when schedules fail to reload or register.

Update imports to use the relocated pkg/config package across affected code.
2026-05-31 15:54:23 +08:00
Akizon77 f86803feff refactor(web): move HTTP route setup out of server
Relocate API routing, OAuth, and Nezha compatibility wiring into the web package to keep server startup focused on initialization concerns and improve code organization.
2026-05-31 15:35:47 +08:00
flyflas a2e3c7218d Merge pull request #525 from flyflas/feat/addXtermjsSetting
Feat/add xtermjs setting
2026-05-31 14:14:16 +08:00
Akizon77 567d2b66fb fix(sessions): 添加移除过期会话功能并更新定时任务 2026-05-08 12:44:37 +08:00
Akizon 473dbb9057 Merge pull request #519 from iamsxm/feature/choose-all
feat(ping): 延迟监测增加默认开启选项
2026-05-07 20:57:29 +08:00
Akizon 89bd47d5df Merge pull request #510 from KanameMadoka520/feature/cloudflared-followup
feat: improve built-in cloudflared tunnel management
2026-05-07 20:52:04 +08:00
Akizon 10c4a9dafc Merge pull request #511 from Lixsp11/db-opt
fix: Optimize database indexes and improve performance on I/O-constrained hosts
2026-05-07 20:51:49 +08:00
Akizon f4d73bf8c9 Merge pull request #504 from paulzzh/patch-1
fix: mjpeg 价格周期与剩余时间显示错误
2026-05-07 20:48:16 +08:00
xiaomi 441e2e9194 feat(ping): 延迟监测增加默认开启选项 2026-05-01 23:21:40 +08:00
lixsp11 c15881c463 fix: Avoid rewriting the database at startup, and create composite indexes to boost query. 2026-04-16 23:04:05 +08:00
KanameMadoka520 f5acbd2efe refactor: narrow cloudflared backend PR scope 2026-04-15 21:49:59 +08:00
KanameMadoka520 e07378c92b docs: document built-in cloudflared support 2026-04-15 07:28:21 +08:00
KanameMadoka520 cd38d32a7d build: bundle cloudflared in the Docker image 2026-04-15 07:18:20 +08:00
KanameMadoka520 53f326390e feat: add managed cloudflared backend APIs 2026-04-15 07:18:10 +08:00
Akizon 651d24128c Merge pull request #509 from airium/fix-timeout-if-empty-exe-result
fix: 防止无返回文本的远程执行结果被后端拒绝而变成超时
2026-04-14 01:32:22 +08:00
airium 2660026917 fix: make task result field optional in request 2026-04-13 21:40:59 +08:00
paulzzh ac7ace27cb refactor: 更名翻译键 2026-04-12 04:43:30 +08:00
paulzzh 1de008269b fix: mjpeg 剩余时间显示错误 2026-04-12 04:32:53 +08:00
paulzzh 4bdbff0377 fix: mjpeg 价格周期显示错误 2026-04-12 04:21:10 +08:00
Akizon 3415c06256 Merge pull request #485 from airium/main
feat: 支持自定义排序延迟检测任务
2026-03-30 16:38:14 +08:00
Akizon 9f3da4c17a Merge pull request #495 from tranquac/fix/ssrf-theme-download
fix: prevent SSRF in theme download by blocking private/internal IPs
2026-03-30 16:37:43 +08:00
quactv 3d0c83557e fix: prevent SSRF in theme download by blocking private/internal IPs
Signed-off-by: tranquac <tranquac@users.noreply.github.com>
2026-03-27 21:50:41 +07:00
airium e319eb7f20 fix: harden ping task ordering update semantics 2026-03-14 23:31:27 +08:00
airium 7f007b7eb9 feat: Add reorder API to ping tasks 2026-03-13 18:18:57 +08:00
Akizon 9e665005e6 Merge pull request #484 from komari-monitor/fix/流量限制溢出
fix: #393 验证流量限制字段为有效的非负整数值
2026-03-10 15:41:33 +08:00
Akizon77 b5b32b5f64 fix: #393 验证流量限制字段为有效的非负整数值 2026-03-10 15:40:53 +08:00
Akizon 89a699b6cb Merge pull request #483 from komari-monitor/fix/IP地址乱码
fix: #363 IP地址出现乱码
2026-03-10 15:29:22 +08:00
Akizon77 79cbf2d2d1 fix: #363 IP地址出现乱码 2026-03-10 15:28:38 +08:00
Akizon ab00c9529e refactor: 按照业务整理api接口 (#482)
* refactor(wip): 按照业务整理API接口

* refactor: 整合认证中间件
fix: #481 Api 鉴权获取UUID为空
2026-03-10 15:23:54 +08:00
Akizon77 21500a6727 refactor: 整合认证中间件
fix: #481 Api 鉴权获取UUID为空
2026-03-10 15:21:02 +08:00
Akizon77 066b944789 refactor(wip): 按照业务整理API接口 2026-03-10 14:37:22 +08:00
Akizon d7dd816a26 Merge pull request #478 from Real-Wanger/main
fix: reset traffic cache when traffic drops
2026-03-04 14:17:34 +08:00
Wanger c5b464c000 fix: reset traffic cache when traffic drops
修复当节点流量被清零重置时,内存中的告警进度缓存未同步清理,导致无法触发后续阈值通知的 Bug。
2026-03-02 14:27:42 +08:00
Akizon 645c733393 Merge pull request #468 from wayjam/main
feat: import theme api #467
2026-02-26 19:46:09 +08:00
Akizon 85088e64ca Merge pull request #471 from vhvy/feat/keepalive-via-post
feat: 支持 HTTP POST 上报实时信息和在线状态管理,添加 HTTP 接口获取 Ping 任务和提交 Ping 结果
2026-02-26 19:40:05 +08:00
Akizon77 896b3e45fc fix: 添加 base_scripts_url 字段到 Legacy 结构体 #472 2026-02-26 19:04:18 +08:00
vhvy 490f951b59 feat: 添加 HTTP 接口获取 Ping 任务和提交 Ping 结果 2026-02-23 21:27:03 +08:00
vhvy 69c96255d7 feat: 支持 HTTP POST 上报实时信息保活及在线状态管理 2026-02-23 21:24:14 +08:00
WayJam So 487542e55d feat: import theme api #467 2026-02-21 16:55:50 +08:00
Akizon ca8fe76c06 Merge pull request #463 from komari-monitor/refactor/增强临时分享的兼容性
refactor: 增强临时分享的兼容性
2026-02-19 00:15:29 +08:00
Akizon77 e6e3619cd2 refactor: 增强临时分享的兼容性 2026-02-19 00:14:23 +08:00
Akizon77 92ab67d5f3 feat: 添加临时访问许可功能,允许使用临时密钥访问私有站点 2026-02-16 00:34:43 +08:00
Akizon77 77f5bef1a5 fix: 配置默认值 2026-02-15 14:17:03 +08:00
Akizon77 58e43a0862 fix: 增强 WebSocket 升级错误消息,包含详细错误信息 2026-02-12 13:41:01 +08:00
Akizon 80c1f2747a Merge pull request #455 from komari-monitor/fix/expire-notification-panic
fix: 修复过期通知的通知提前天数类型转换错误
2026-02-12 13:13:12 +08:00
Akizon77 b7b2d8b050 fix: 修复过期通知的通知提前天数类型转换错误 2026-02-12 13:12:16 +08:00
Akizon77 b9eabd7cdc docs: 添加 1Panel 应用商店的安装说明 2026-02-10 22:54:48 +08:00
Akizon77 0a19465140 docs: 添加破碎工坊云的介绍和链接 2026-02-09 23:08:57 +08:00
Akizon e874b5db18 Merge pull request #451 from komari-monitor/fix/config-panic
Fix/config panic
2026-02-08 15:49:00 +08:00
Akizon77 23e7d97e3c fix: 流量提醒 2026-02-08 15:48:02 +08:00
Akizon77 a222de2c55 fix: 修复 GetAs 函数中的默认值类型不匹配错误 2026-02-08 13:26:43 +08:00
Akizon bb1eed1ccb Merge pull request #448 from komari-monitor/fix-path-traversal-vulnerability
fix: 修复路径穿透安全漏洞
2026-02-07 23:39:57 +08:00
Akizon77 c34bc8da69 fix: 修复路径穿透安全漏洞
- 添加 isSafePath 函数验证路径是否在允许的目录内
- 在 getFileContent 中验证主题 ID 和文件路径
- 清理所有用户输入的路径,防止 ../ 等路径穿透攻击
- 确保嵌入式文件系统路径也经过验证

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

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

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

* feat:移除nsmao接口支持。

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

* ci: 添加自动合并

* ci: 申请auto-merge权限
2025-08-06 06:36:49 +00:00
Akizon77 0b34beea59 ci: 申请auto-merge权限 2025-08-06 06:35:50 +00:00
Akizon77 90ee9bc731 ci: 添加自动合并 2025-08-06 06:25:39 +00:00
Akizon77 046f38e7c4 fix: 安装时多一个嵌套的/data 2025-08-06 06:07:23 +00:00
Akizon eb733cea45 Merge pull request #168 from dbedu/main
feat: support theme online update.
2025-08-06 12:39:09 +08:00
Akizon eb68f0c629 Merge pull request #167 from komari-monitor/dev
feat: #162 添加Webhook消息发送功能
2025-08-05 15:36:41 +08:00
Akizon77 1cbcac4a00 fix: 修正拼写错误 2025-08-05 07:35:28 +00:00
Akizon77 2d6d58edab feat: #162 添加Webhook消息发送功能 2025-08-05 07:34:05 +00:00
Akizon77 c01fadbb48 feat: 添加option选项 2025-08-05 07:26:11 +00:00
Akizon77 41ef2e239d fix: 更新任务结果保存逻辑以使用模型中的时间类型 2025-08-05 06:44:24 +00:00
Akizon77 7176180343 fix: 修复任务创建时未包含离线客户端 2025-08-05 06:32:45 +00:00
Akizon77 0b9a01c8ac fix: 更新Exec函数以处理离线客户端并保存任务结果 2025-08-05 05:56:00 +00:00
Akizon 50ef62a64a Merge pull request #163 from imlonghao/fix/oidc
fix(oidc): 修正请求 token 阶段的请求包
2025-08-05 13:17:30 +08:00
Akizon 384fa4bbe1 Merge pull request #165 from JohnsonRan/bark
feat: add Bark notification provider
2025-08-04 22:36:42 +08:00
JohnsonRan 10f84a8052 feat: add Bark notification provider 2025-08-04 22:09:49 +08:00
imlonghao 53bd83af8b fix(oidc): 修正请求 token 阶段的请求包 2025-08-04 21:22:20 +08:00
dbedu 53577e21e7 feat: support update theme from github release 2025-08-04 21:01:27 +08:00
dbedu 46c0cb9bfb feat: support update theme from github release 2025-08-04 20:48:10 +08:00
dbedu ac8ee8f9fa feat: add theme online update 2025-08-04 19:56:43 +08:00
Akizon 787e8bdd80 Merge pull request #160 from imlonghao/fix/oidc-redirect-uri
fix(oidc): 增加 redirect_uri
2025-08-03 21:48:47 +08:00
Akizon 83a39e3143 Merge pull request #161 from imlonghao/fix/oidc-reload-config
fix(oidc): 修复配置无法更新的问题
2025-08-03 21:43:21 +08:00
imlonghao 5e2ed79929 fix(oidc): 修复配置无法更新的问题 2025-08-03 21:40:54 +08:00
imlonghao c8f0ba926d fix(oidc): 增加 redirect_uri 2025-08-03 21:22:37 +08:00
Akizon77 17e0e14aef fix: 初次启动时无法加载oauth 2025-08-03 09:54:07 +00:00
Akizon77 731602a80d Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-03 09:42:55 +00:00
Akizon77 37f0e449de fix: 优化OIDC提供者加载逻辑,添加空配置处理 2025-08-03 09:42:52 +00:00
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
234 changed files with 23800 additions and 3491 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
+17 -6
View File
@@ -1,9 +1,13 @@
name: Build Binaries on Main Push
name: Build Binaries on Main Push and PR
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
build-binaries:
@@ -61,13 +65,20 @@ jobs:
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
@@ -103,4 +114,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
+33 -14
View File
@@ -1,8 +1,9 @@
name: Cleanup Old Packages
on:
schedule:
- cron: '0 0 * * 0' # Run every Sunday at midnight
#schedule:
# - cron: '0 0 * * 0' # Run every Sunday at midnight
# 会清理掉正常的镜像
workflow_dispatch: # Allow manual trigger
jobs:
@@ -20,23 +21,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);
// Identify versions with only a single SHA tag (format: sha-<7 hexadecimal characters>)
const shaOnlyVersions = packages.filter(pkg => {
// 筛选仅有 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}$/);
return (
(tags.length === 1 && (
tags[0].match(/^sha-[0-9a-f]{7}$/) ||
tags[0].match(/^sha256:[0-9a-f]{64}$/)
)) ||
tags.length === 0
);
});
// Delete the SHA-only versions
for (const version of shaOnlyVersions) {
// 删除这些版本
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} with SHA tag ${version.metadata.container.tags[0]}`);
}
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
}
+16 -9
View File
@@ -12,22 +12,29 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v4
#- name: Create dummy index.html
# run: |
# mkdir -p public/dist
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
# mkdir -p web/public/defaultTheme/dist
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > web/public/defaultTheme/dist/index.html
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
+15 -8
View File
@@ -1,9 +1,9 @@
name: Publish Docker Image
name: Publish Docker Image on Main
on:
push:
branches:
- main
#push:
# branches:
# - main
workflow_dispatch:
env:
@@ -28,13 +28,20 @@ jobs:
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
@@ -0,0 +1,62 @@
name: Generate Release Notes
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to regenerate notes for."
required: true
type: string
permissions:
contents: write
jobs:
update-release-notes:
runs-on: ubuntu-latest
steps:
- name: Generate and apply release notes
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || core.getInput("tag");
if (!tag) {
core.setFailed("No release tag was provided.");
return;
}
const { owner, repo } = context.repo;
const release = context.payload.release?.tag_name === tag
? context.payload.release
: (await github.rest.repos.getReleaseByTag({ owner, repo, tag })).data;
const generated = await github.request(
"POST /repos/{owner}/{repo}/releases/generate-notes",
{
owner,
repo,
tag_name: tag,
target_commitish: release.target_commitish || undefined,
},
);
const body = generated.data.body?.trim();
if (!body) {
core.setFailed(`GitHub returned empty release notes for ${tag}.`);
return;
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
body,
});
await core.summary
.addHeading("Release Notes")
.addRaw(`Updated release notes for ${tag}.`)
.write();
+13 -4
View File
@@ -31,13 +31,20 @@ jobs:
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
@@ -96,3 +103,5 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
+11 -4
View File
@@ -68,13 +68,20 @@ jobs:
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
+6
View File
@@ -7,12 +7,14 @@
*.dll
*.so
*.dylib
__debug*
# local development data
komari.db
/data
/utils/geoip/data
.vscode/
/backup
# Test binary, built with `go test -c`
*.test
@@ -29,3 +31,7 @@ go.work.sum
# env file
.env
# build
komari
.codex
+14 -1
View File
@@ -6,6 +6,19 @@ WORKDIR /app
ARG TARGETOS
ARG TARGETARCH
RUN apk add --no-cache ca-certificates curl tzdata
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) cloudflared_arch="amd64" ;; \
386) cloudflared_arch="386" ;; \
arm64) cloudflared_arch="arm64" ;; \
arm) cloudflared_arch="arm" ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflared_arch}" -o /usr/local/bin/cloudflared; \
chmod +x /usr/local/bin/cloudflared
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN chmod +x /app/komari
@@ -22,4 +35,4 @@ ENV KOMARI_LISTEN=0.0.0.0:25774
EXPOSE 25774
CMD ["/app/komari", "server"]
CMD ["/app/komari", "server"]
+27
View File
@@ -0,0 +1,27 @@
CoreOS Project
Copyright 2014 CoreOS, Inc
This product includes software developed at CoreOS, Inc.
(http://www.coreos.com/).
otp
Copyright (c) 2014, Paul Querna
This product includes software developed by
Paul Querna (http://paul.querna.org/).
Copyright 2014 gRPC authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+89 -39
View File
@@ -1,27 +1,51 @@
# Komari
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/komari-monitor/komari)
[文档](https://komari-monitor.github.io/komari-document/)
![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)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
[简体中文](./docs/README_zh.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.md)
## 快速开始
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.
### 依赖
- Docker(快速部署)
- 或者 Go 1.18+ 和 Node.js 20+(手工构建)
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
### Docker 部署
1. 创建数据目录:
## 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.
## 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_)
- 1Panel App Store
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
### 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,63 +53,89 @@ 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.
### Manual Build
### 手工构建
1. 构建前端静态文件:
#### 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` 文件夹。
```bash
Copy the static files generated in step 1 to the `/web/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/web/public/defaultTheme`.
```bash
go build -o komari
```
4. 运行:
3. 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
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-monitor.github.io/komari-document/dev/agent.html)
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
## 贡献
欢迎提交 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!
## Acknowledgements
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 Sponsored by SharonNetworks
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
### The open source software community
All the developers who submitted PRs and created themes
## Star History
-35
View File
@@ -1,35 +0,0 @@
package api
import (
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/gin-gonic/gin"
)
func AdminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
// Komari is a single user system
uuid, err := accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
accounts.UpdateLatest(session, c.Request.UserAgent(), c.ClientIP())
// 将 session 和 用户 UUID 传递到后续处理器
c.Set("session", session)
c.Set("uuid", uuid)
c.Next()
}
}
-121
View File
@@ -1,121 +0,0 @@
package api
import (
"log"
"net/http"
"sync"
"time"
"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/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
)
var (
Records = cache.New(1*time.Minute, 1*time.Minute)
)
type TerminalSession struct {
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).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.Set(uuid, filtered, cache.DefaultExpiration)
// 计算平均报告并添加到记录列表
if len(filtered) > 0 {
r := utils.AverageReport(uuid, time.Now(), filtered, 0.3)
records = append(records, 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()
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
log.Printf("Failed to save records to database: %v", err)
return err
}
}
return nil
}
type Response struct {
Status string `json:"status"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Respond sends a standardized JSON response.
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
}
// RespondSuccess sends a success response with data.
func RespondSuccess(c *gin.Context, data interface{}) {
Respond(c, http.StatusOK, "success", "", data)
}
// RespondSuccessMessage sends a success response with message and data.
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
Respond(c, http.StatusOK, "success", message, data)
}
// RespondError sends an error response with message.
func RespondError(c *gin.Context, httpStatus int, message string) {
Respond(c, httpStatus, "error", message, nil)
}
func GetVersion(c *gin.Context) {
RespondSuccess(c, gin.H{
"version": utils.CurrentVersion,
"hash": utils.VersionHash,
})
}
-81
View File
@@ -1,81 +0,0 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"net/http"
"github.com/komari-monitor/komari/database/clients"
"gorm.io/gorm"
"github.com/gin-gonic/gin"
)
// TokenAuthMiddleware creates a Gin middleware that validates a token from query parameters or request body.
func TokenAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
var token string
// Step 1: Check query parameter for token
token = c.Query("token")
// Step 2: If no token in query, check request body for non-GET requests
if token == "" && c.Request.Method != http.MethodGet {
// Read the body
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to read request body"})
return
}
// Restore the body for downstream handlers
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// Try to extract token from JSON body
var bodyMap map[string]interface{}
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &bodyMap); err == nil {
if tokenVal, exists := bodyMap["token"]; exists {
if str, ok := tokenVal.(string); ok && str != "" {
token = str
}
}
}
}
}
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "token is required"})
return
}
exists, err := checkTokenExists(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to validate token"})
return
}
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "invalid token"})
return
}
c.Next()
}
}
func checkTokenExists(token string) (bool, error) {
_, err := clients.GetClientUUIDByToken(token)
if err == sql.ErrNoRows {
return false, nil
}
if err == gorm.ErrRecordNotFound {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
-139
View File
@@ -1,139 +0,0 @@
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"
)
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
func DownloadBackup(c *gin.Context) {
backupFileName := fmt.Sprintf("backup-%s.zip", time.Now().Format("20060102-150405"))
c.Writer.Header().Set("Content-Type", "application/zip")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
zipWriter := zip.NewWriter(c.Writer)
defer zipWriter.Close()
err := filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
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) {
dbFileName := filepath.Base(dbFilePath)
dbInfo, err := os.Stat(dbFilePath)
if err != nil {
if os.IsNotExist(err) {
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
return // 数据库不存在是正常情况,直接返回
}
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
if !dbInfo.IsDir() {
file, err := os.Open(dbFilePath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening database file: %v", err))
return
}
defer file.Close()
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: dbFileName,
Method: zip.Deflate,
Modified: dbInfo.ModTime(),
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating zip entry for database file: %v", err))
return
}
_, err = io.Copy(writer, file)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing database file to zip: %v", err))
return
}
}
} else {
log.Printf("Database file '%s' is within './data', skipping explicit addition.\n", dbFilePath)
}
// zipWriter.Close() 由 defer 自动调用
}
-78
View File
@@ -1,78 +0,0 @@
package admin
import (
"encoding/json"
"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/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
)
// 接受数据类型:
// - command: string
// - clients: []string (客户端 UUID 列表)
func Exec(c *gin.Context) {
var req struct {
Command string `json:"command" binding:"required"`
Clients []string `json:"clients" binding:"required"`
}
var onlineClients []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) {
onlineClients = append(onlineClients, uuid)
} else {
api.RespondError(c, 400, "Client not connected: "+uuid)
return
}
}
if len(onlineClients) == 0 {
api.RespondError(c, 400, "No clients connected")
return
}
taskId := utils.GenerateRandomString(16)
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
api.RespondError(c, 500, "Failed to create task: "+err.Error())
return
}
for _, uuid := range onlineClients {
var send struct {
Message string `json:"message"`
Command string `json:"command"`
TaskId string `json:"task_id"`
}
send.Message = "exec"
send.Command = req.Command
send.TaskId = taskId
payload, _ := json.Marshal(send)
client := ws.GetConnectedClients()[uuid]
if client != nil {
client.WriteMessage(websocket.TextMessage, payload)
} else {
api.RespondError(c, 400, "Client connection is null: "+uuid)
return
}
}
uuid, _ := c.Get("uuid")
logOperation.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
}
}
return false
}
-1
View File
@@ -1 +0,0 @@
package notification
-35
View File
@@ -1,35 +0,0 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
)
func BindingExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil {
api.RespondError(c, 500, "No user found: "+err.Error())
return
}
c.SetCookie("binding_external_account", user.UUID, 3600, "/", "", false, true)
c.Redirect(302, "/api/oauth")
}
func UnbindExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil {
api.RespondError(c, 500, "No user found: "+err.Error())
return
}
err = accounts.UnbindExternalAccount(user.UUID)
if err != nil {
api.RespondError(c, 500, "Failed to unbind external account: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
-80
View File
@@ -1,80 +0,0 @@
package admin
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/tasks"
)
// POST body: clients []string, target, task_type string, interval int
func AddPingTask(c *gin.Context) {
var req struct {
Clients []string `json:"clients" binding:"required"`
Name string `json:"name" binding:"required"`
Target string `json:"target" binding:"required"`
TaskType string `json:"type" binding:"required"` // icmp, tcp, http
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if taskID, err := tasks.AddPingTask(req.Clients, req.Name, req.Target, req.TaskType, 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 DeletePingTask(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 := tasks.DeletePingTask(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 EditPingTask(c *gin.Context) {
var req struct {
Tasks []*models.PingTask `json:"tasks" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
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})
// }
api.RespondSuccess(c, nil)
}
}
func GetAllPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, tasks)
}
-193
View File
@@ -1,193 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"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/ws"
"github.com/patrickmn/go-cache"
)
func UploadReport(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Println("Failed to read request body:", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
var data map[string]interface{}
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Save report to database
var report common.Report
err = json.Unmarshal(bodyBytes, &report)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
report.UpdatedAt = time.Now()
err = SaveClientReport(report.UUID, report)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
return
}
// Update report with method and token
ws.GetLatestReport()[report.UUID] = &report
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
}
func WebSocketReport(c *gin.Context) {
// 升级ws
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// Upgrade the HTTP connection to a WebSocket connection
unsafeConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
conn := ws.NewSafeConn(unsafeConn)
defer conn.Close()
_, message, err := conn.ReadMessage()
if err != nil {
log.Println("Error reading message:", err)
return
}
// 第一次数据拿token
data := map[string]interface{}{}
err = json.Unmarshal(message, &data)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
return
}
// it should ok,token was verfied in the middleware
token := ""
var errMsg string
// 优先检查查询参数中的 token
token = c.Query("token")
// 如果 token 为空,返回错误
if token == "" {
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
return
}
uuid, err := clients.GetClientUUIDByToken(token)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
return
}
// 只允许一个客户端的连接
if _, exists := ws.GetConnectedClients()[uuid]; exists {
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
return
}
ws.SetConnectedClients(uuid, conn)
go notification.OnlineNotification(uuid)
defer func() {
ws.DeleteConnectedClients(uuid)
notification.OfflineNotification(uuid)
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
type MessageType struct {
Type string `json:"type"`
}
var msgType MessageType
err = json.Unmarshal(message, &msgType)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
continue
}
switch msgType.Type {
case "", "report":
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
continue
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
continue
}
ws.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
continue
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
}
}
}
func SaveClientReport(uuid string, report common.Report) error {
reports, _ := api.Records.Get(uuid)
if reports == nil {
reports = []common.Report{}
}
if report.CPU.Usage < 0.01 {
report.CPU.Usage = 0.01
}
reports = append(reports.([]common.Report), report)
api.Records.Set(uuid, reports, cache.DefaultExpiration)
return nil
}
-51
View File
@@ -1,51 +0,0 @@
package client
import (
"net"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/gin-gonic/gin"
)
func UploadBasicInfo(c *gin.Context) {
var cbi = map[string]interface{}{}
if err := c.ShouldBindJSON(&cbi); err != nil {
c.JSON(400, gin.H{"status": "error", "error": "Invalid or missing data"})
return
}
token := c.Query("token")
uuid, err := clients.GetClientUUIDByToken(token)
if uuid == "" || err != nil {
c.JSON(400, gin.H{"status": "error", "error": "Invalid token"})
return
}
cbi["uuid"] = uuid
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoInfo(ip4)
if ip4_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
}
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
ip6 := net.ParseIP(ipv6)
ip6_record, _ := geoip.GetGeoInfo(ip6)
if ip6_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
}
}
}
if err := clients.SaveClientInfo(cbi); err != nil {
c.JSON(500, gin.H{"status": "error", "error": err})
return
}
c.JSON(200, gin.H{"status": "success"})
}
-15
View File
@@ -1,15 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
)
func GetClientRecentRecords(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
RespondError(c, 400, "UUID is required")
return
}
records, _ := Records.Get(uuid)
RespondSuccess(c, records)
}
-75
View File
@@ -1,75 +0,0 @@
package api
import (
"encoding/json"
"io"
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/gin-gonic/gin"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TwoFa string `json:"2fa_code"`
}
func Login(c *gin.Context) {
conf, _ := config.Get()
if conf.DisablePasswordLogin {
RespondError(c, http.StatusForbidden, "Password login is disabled")
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
var data LoginRequest
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
if data.Username == "" || data.Password == "" {
RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
return
}
uuid, success := accounts.CheckPassword(data.Username, data.Password)
if !success {
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
return
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" && data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.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")
c.Redirect(302, "/")
}
-26
View File
@@ -1,26 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
)
func GetNodesInformation(c *gin.Context) {
clientList, err := clients.GetAllClientBasicInfo()
if err != nil {
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
return
}
count := len(clientList)
// 公开信息不展示IP地址,私有备注不展示
for i := 0; i < count; i++ {
clientList[i].IPv4 = ""
clientList[i].IPv6 = ""
clientList[i].Remark = ""
clientList[i].Version = ""
clientList[i].Token = ""
}
RespondSuccess(c, clientList)
}
-97
View File
@@ -1,97 +0,0 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/utils/oauth"
)
// /api/oauth
func OAuth(c *gin.Context) {
cfg, _ := config.Get()
if !cfg.OAuthEnabled {
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
return
}
authURL, state := oauth.GetOAuthUrl()
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
c.Redirect(302, authURL)
}
// /api/oauth_callback
func OAuthCallback(c *gin.Context) {
// 验证state防止CSRF攻击
state, _ := c.Cookie("oauth_state")
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
// 获取code
code := c.Query("code")
if code == "" {
c.JSON(400, gin.H{"status": "error", "error": "No code provided"})
return
}
githubUser, err := oauth.OAuthCallback(code, state)
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)
// 如果cookie中有binding_external_account,说明是绑定外部账号
// 否则是登录
uuid, _ := c.Cookie("binding_external_account")
c.SetCookie("binding_external_account", "", -1, "/", "", false, true)
if uuid != "" {
// 绑定外部账号
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil || user.UUID != uuid {
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
return
}
err = accounts.BindingExternalAccount(user.UUID, sso_id)
if err != nil {
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")
c.Redirect(302, "/manage")
return
}
// 尝试获取用户
user, err := accounts.GetUserBySSO(sso_id)
if err != nil {
c.JSON(401, gin.H{
"status": "error",
"message": "please log in and bind your GitHub account first.",
})
return
}
// 创建会话
session, err := accounts.CreateSession(user.UUID, 2592000, c.Request.UserAgent(), c.ClientIP(), "oauth")
if err != nil {
c.JSON(500, gin.H{"status": "error", "message": err.Error()})
return
}
// 设置cookie并返回
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
c.Redirect(302, "/manage")
}
-28
View File
@@ -1,28 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
)
func GetPublicSettings(c *gin.Context) {
cst, err := config.Get()
if err != nil {
RespondError(c, 500, err.Error())
return
}
// Return public settings including CORS
RespondSuccess(c, gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
})
}
-110
View File
@@ -1,110 +0,0 @@
package record
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
records "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
)
func GetRecordsByUUID(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
}
records, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{
"records": records,
"count": len(records),
})
}
// GET query: uuid string, hours int
func GetPingRecords(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
}
records, err := tasks.GetPingRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
response := gin.H{
"records": records,
"count": len(records),
}
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 := false
for _, client := range t.Clients {
if client == uuid {
found = true
break
}
}
if !found {
continue
}
// 只返回有记录的任务
if _, ok := taskIdSet[t.Id]; ok {
tasksList = append(tasksList, gin.H{
"id": t.Id,
"name": t.Name,
"interval": t.Interval,
})
}
}
response["tasks"] = tasksList
}
api.RespondSuccess(c, response)
}
-169
View File
@@ -1,169 +0,0 @@
package api
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"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"
)
func RequestTerminal(c *gin.Context) {
uuid := c.Param("uuid")
user_uuid, _ := c.Get("uuid")
_, err := clients.GetClientByUUID(uuid)
if err != nil {
c.JSON(400, gin.H{
"status": "error",
"message": "Client not found",
})
return
}
// 建立ws
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Require WebSocket upgrade"})
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
// 新建一个终端连接
id := utils.GenerateRandomString(32)
session := &TerminalSession{
UserUUID: user_uuid.(string),
UUID: uuid,
Browser: conn,
Agent: nil,
RequesterIp: c.ClientIP(),
}
TerminalSessionsMutex.Lock()
TerminalSessions[id] = session
TerminalSessionsMutex.Unlock()
conn.SetCloseHandler(func(code int, text string) error {
log.Println("Terminal connection closed:", code, text)
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
// 通知 Agent 关闭终端连接
if session.Agent != nil {
session.Agent.Close()
}
return nil
})
if ws.GetConnectedClients()[uuid] == nil {
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!"))
conn.Close()
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
return
}
err = ws.GetConnectedClients()[uuid].WriteJSON(gin.H{
"message": "terminal",
"request_id": id,
})
if err != nil {
conn.Close()
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
return
}
conn.WriteMessage(1, []byte("等待被控端连接 waiting for agent..."))
// 如果没有连接上,则关闭连接
time.AfterFunc(30*time.Second, func() {
TerminalSessionsMutex.Lock()
if session.Agent == nil {
if session.Browser != nil {
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout"))
session.Browser.Close()
}
conn.Close()
delete(TerminalSessions, id)
}
TerminalSessionsMutex.Unlock()
})
//logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
}
func ForwardTerminal(id string) {
session, exists := TerminalSessions[id]
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
return
}
logOperation.Log(session.RequesterIp, session.UserUUID, "established, terminal id:"+id, "terminal")
established_time := time.Now()
errChan := make(chan error, 1)
go func() {
for {
messageType, data, err := session.Browser.ReadMessage()
if err != nil {
errChan <- err
return
}
if messageType == websocket.TextMessage {
if session.Agent != nil && string(data[0:1]) == "{" {
err = session.Agent.WriteMessage(websocket.TextMessage, data)
} else if session.Agent != nil {
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
} else if session.Agent != nil {
// 二进制消息,原样传递
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
if err != nil {
errChan <- err
return
}
}
}()
go func() {
for {
_, data, err := session.Agent.ReadMessage()
if err != nil {
errChan <- err
return
}
if session.Browser != nil {
err = session.Browser.WriteMessage(websocket.BinaryMessage, data)
if err != nil {
errChan <- err
return
}
}
}
}()
// 等待错误或主动关闭
<-errChan
// 关闭连接
if session.Agent != nil {
session.Agent.Close()
}
if session.Browser != nil {
session.Browser.Close()
}
disconnect_time := time.Now()
logOperation.Log(session.RequesterIp, session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
}
+33 -6
View File
@@ -1,14 +1,41 @@
package flags
import "strings"
const (
DatabaseTypeSQLite = "sqlite"
)
var (
// 数据库配置
DatabaseType string // 数据库类型:sqlite, mysql
DatabaseType string // 数据库类型:sqlite
DatabaseFile string // SQLite数据库文件路径
DatabaseHost string // MySQL/其他数据库主机地址
DatabasePort string // MySQL/其他数据库端口
DatabaseUser string // MySQL/其他数据库用户名
DatabasePass string // MySQL/其他数据库密码
DatabaseName string // MySQL/其他数据库名称
DatabaseHost string // 保留的兼容参数,当前未使用
DatabasePort string // 保留的兼容参数,当前未使用
DatabaseUser string // 保留的兼容参数,当前未使用
DatabasePass string // 保留的兼容参数,当前未使用
DatabaseName string // 保留的兼容参数,当前未使用
Listen string
)
func NormalizeDatabaseType(databaseType string) string {
databaseType = strings.ToLower(strings.TrimSpace(databaseType))
if databaseType == "" {
return DatabaseTypeSQLite
}
return databaseType
}
func ApplyDatabaseTypeNormalization() string {
DatabaseType = NormalizeDatabaseType(DatabaseType)
return DatabaseType
}
func IsSQLite() bool {
return NormalizeDatabaseType(DatabaseType) == DatabaseTypeSQLite
}
func SupportedDatabaseTypes() string {
return DatabaseTypeSQLite
}
+20
View File
@@ -0,0 +1,20 @@
package flags
import "testing"
func TestNormalizeDatabaseType(t *testing.T) {
tests := map[string]string{
"": DatabaseTypeSQLite,
"sqlite": DatabaseTypeSQLite,
" SQLite ": DatabaseTypeSQLite,
"SQLITE": DatabaseTypeSQLite,
"postgres": "postgres",
" postgres": "postgres",
}
for input, want := range tests {
if got := NormalizeDatabaseType(input); got != want {
t.Fatalf("NormalizeDatabaseType(%q) = %q, want %q", input, got, want)
}
}
}
+3 -8
View File
@@ -4,9 +4,8 @@ import (
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
var PermitPasswordLoginCmd = &cobra.Command{
@@ -14,12 +13,8 @@ var PermitPasswordLoginCmd = &cobra.Command{
Short: "Force permit password login",
Long: `Force permit password login`,
Run: func(cmd *cobra.Command, args []string) {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.Config{}).Where("id = ?", 1).
Update("disable_password_login", false).Error
})
if err != nil {
dbcore.GetDBInstance()
if err := config.Set(config.DisablePasswordLoginKey, false); err != nil {
cmd.Println("Error:", err)
os.Exit(1)
}
+9 -6
View File
@@ -48,11 +48,14 @@ func Execute() {
func init() {
// 设置命令行参数,提供环境变量作为默认值
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite, mysql) [env: KOMARI_DB_TYPE]")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite) [env: KOMARI_DB_TYPE]")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "MySQL/Other database host address [env: KOMARI_DB_HOST]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "MySQL/Other database port [env: KOMARI_DB_PORT]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "MySQL/Other database username [env: KOMARI_DB_USER]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "MySQL/Other database password [env: KOMARI_DB_PASS]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "MySQL/Other database name [env: KOMARI_DB_NAME]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "Reserved database host parameter [env: KOMARI_DB_HOST]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "Reserved database port parameter [env: KOMARI_DB_PORT]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "Reserved database username parameter [env: KOMARI_DB_USER]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "Reserved database password parameter [env: KOMARI_DB_PASS]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "Reserved database name parameter [env: KOMARI_DB_NAME]")
for _, name := range []string{"db-host", "db-port", "db-user", "db-pass", "db-name"} {
_ = RootCmd.PersistentFlags().MarkHidden(name)
}
}
+175 -273
View File
@@ -2,267 +2,48 @@ package cmd
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/api/admin"
"github.com/komari-monitor/komari/api/admin/clipboard"
log_api "github.com/komari-monitor/komari/api/admin/log"
"github.com/komari-monitor/komari/api/admin/notification"
"github.com/komari-monitor/komari/api/admin/test"
"github.com/komari-monitor/komari/api/admin/update"
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/web/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/auditlog"
"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/pkg/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
logutil "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
u_notification "github.com/komari-monitor/komari/utils/notification"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/web/nezha"
"github.com/komari-monitor/komari/web/oauth"
report_cache "github.com/komari-monitor/komari/web/report"
"github.com/komari-monitor/komari/web/router"
"github.com/komari-monitor/komari/web/security"
"github.com/spf13/cobra"
)
var (
DynamicCorsEnabled bool = false
)
var ServerCmd = &cobra.Command{
Use: "server",
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()
go messageSender.Initialize()
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 中间件
conf, err := config.Get()
if err != nil {
log.Fatal(err)
}
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(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())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
// 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
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)
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)
}
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()
},
}
@@ -273,19 +54,124 @@ func init() {
RootCmd.AddCommand(ServerCmd)
}
func RunServer() {
// #region 初始化
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.GetManyAs[config.Settings]()
if err != nil {
log.Fatal(err)
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
// oidcInit
go oauth.Initialize()
if conf.NezhaCompatEnabled {
go func() {
if err := nezha.StartNezhaCompat(conf.NezhaCompatListen); err != nil {
log.Printf("Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
}
}()
}
config.Subscribe(func(event config.ConfigEvent) {
if ok, t := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
if t == "" || t == "none" {
t = "github"
}
oidcProvider, err := database.GetOidcConfigByName(t)
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 ok, t := config.IsChangedT[bool](event, config.NezhaCompatEnabledKey); ok {
if t {
l, _ := config.GetAs[string](config.NezhaCompatListenKey)
if err := nezha.StartNezhaCompat(l); err != nil {
log.Printf("start Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
}
} else {
if err := nezha.StopNezhaCompat(); err != nil {
log.Printf("stop Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
}
}
}
})
// 初始化 cloudflared
if err := cloudflared.AutoStart(GetEnv("KOMARI_CLOUDFLARED_TOKEN", "")); err != nil {
log.Printf("failed to auto start cloudflared: %v", err)
}
r := gin.New()
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
config.Subscribe(func(event config.ConfigEvent) {
if event.IsChanged(config.GeoIpProviderKey) {
go geoip.InitGeoIp()
}
if event.IsChanged(config.NotificationMethodKey) {
go messageSender.Initialize()
}
})
r.Use(security.CorsMiddleware(conf.CorsOriginCheckEnabled, conf.CorsAllowedOrigins))
r.Use(api.IdentityMiddleware())
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()
})
router.Register(r)
srv := &http.Server{
Addr: flags.Listen,
Handler: r,
}
log.Printf("Starting server on %s ...", flags.Listen)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
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" {
// log.Printf("使用 MySQL 数据库连接: %s@%s:%s/%s",
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_HOST=%s] [KOMARI_DB_PORT=%s] [KOMARI_DB_USER=%s] [KOMARI_DB_NAME=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_HOST"), os.Getenv("KOMARI_DB_PORT"),
// os.Getenv("KOMARI_DB_USER"), os.Getenv("KOMARI_DB_NAME"))
// } else {
// log.Printf("使用 SQLite 数据库文件: %s", flags.DatabaseFile)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_FILE=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_FILE"))
// }
var count int64 = 0
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
user, passwd, err := accounts.CreateDefaultAdminAccount()
@@ -298,38 +184,54 @@ func InitDatabase() {
// #region 定时任务
func DoScheduledWork() {
tasks.ReloadPingSchedule()
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 u_notification.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.PingRecordPreserveTime)))
logOperation.RemoveOldLogs()
case <-minute.C:
api.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
}
if err := tasks.ReloadPingSchedule(); err != nil {
log.Println("Failed to reload ping schedule:", err)
}
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
log.Println("Failed to reload load notification schedule:", err)
}
records.CompactRecord()
if err := corn.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
log.Println("Failed to add cleanup scheduled task:", err)
}
if err := corn.AddFunc("records:minute", "@every 1m", minuteScheduledWork); err != nil {
log.Println("Failed to add minute scheduled task:", err)
}
if err := corn.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
log.Println("Failed to add expire notification scheduled task:", err)
}
notifier.InitTrafficReportSchedule()
}
func cleanupScheduledData() {
cfg, _ := config.GetManyAs[config.Settings]()
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.PingRecordPreserveTime)))
auditlog.RemoveOldLogs()
accounts.RemoveExpiredSessions()
}
func minuteScheduledWork() {
cfg, _ := config.GetManyAs[config.Settings]()
report_cache.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
// 每分钟检查一次流量提醒
notifier.CheckTraffic()
}
func OnShutdown() {
logOperation.Log("", "", "server is shutting down", "info")
cloudflared.Kill()
auditlog.Log("", "", "server is shutting down", "info")
corn.StopAll()
cloudflared.Shutdown()
}
func OnFatal(err error) {
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
cloudflared.Kill()
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
cloudflared.Shutdown()
}
-117
View File
@@ -1,117 +0,0 @@
package common
import "time"
type Message struct {
Type string `json:"type"`
Content string `json:"content"`
Sender string `json:"sender"`
Timestamp int64 `json:"timestamp"`
}
type ClientConfig struct {
ClientUUID string `json:"client_uuid" gorm:"type:uuid;primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
CPU bool `json:"cpu" gorm:"default:true"`
GPU bool `json:"gpu" gorm:"default:true"`
RAM bool `json:"ram" gorm:"default:true"`
SWAP bool `json:"swap" gorm:"default:true"`
LOAD bool `json:"load" gorm:"default:true"`
UPTIME bool `json:"uptime" gorm:"default:true"`
TEMP bool `json:"temp" gorm:"default:true"`
OS bool `json:"os" gorm:"default:true"`
DISK bool `json:"disk" gorm:"default:true"`
NET bool `json:"net" gorm:"default:true"`
PROCESS bool `json:"process" gorm:"default:true"`
Connections bool `json:"connections" gorm:"default:true"`
Interval int `json:"interval" gorm:"default:3;check:Interval >= 1"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ClientInfo stores static client information
// Deprecated: Use models.Client instead.
type ClientInfo struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Name string `json:"name" gorm:"type:varchar(100);not null"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time
UpdatedAt time.Time
}
type IPAddress struct {
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
}
type Report struct {
UUID string `json:"uuid,omitempty"`
CPU CPUReport `json:"cpu"`
Ram RamReport `json:"ram"`
Swap RamReport `json:"swap"`
Load LoadReport `json:"load"`
Disk DiskReport `json:"disk"`
Network NetworkReport `json:"network"`
Connections ConnectionsReport `json:"connections"`
Uptime int64 `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
Method string `json:"method,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type CPUReport struct {
Name string `json:"name,omitempty"`
Cores int `json:"cores,omitempty"`
Arch string `json:"arch,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type GPUReport struct {
Name string `json:"name,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type RamReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type LoadReport struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
type DiskReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type NetworkReport struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
TotalUp int64 `json:"totalUp"`
TotalDown int64 `json:"totalDown"`
}
type ConnectionsReport struct {
TCP int `json:"tcp"`
UDP int `json:"udp"`
}
+32
View File
@@ -2,11 +2,17 @@ package accounts
import (
"errors"
"fmt"
"net"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
)
// GetAllSessions 获取所有会话
@@ -33,6 +39,23 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
LoginMethod: login_method,
LatestOnline: models.FromTime(time.Now()),
}
go func() {
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
if LoginNotification {
ipAddr := net.ParseIP(ip)
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
loc := "unknown"
if ipinfo != nil && ipinfo.Name != "" {
loc = ipinfo.Name
}
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Login,
Time: time.Now(),
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
Emoji: "🔑",
})
}
}()
err := db.Create(&sessionRecord).Error
if err != nil {
@@ -110,3 +133,12 @@ func UpdateLatest(session, useragent, ip string) error {
"latest_ip": ip,
}).Error
}
func RemoveExpiredSessions() error {
db := dbcore.GetDBInstance()
result := db.Where("expires < ?", time.Now()).Delete(&models.Session{})
if result.Error != nil {
return result.Error
}
return nil
}
@@ -1,4 +1,4 @@
package logOperation
package auditlog
import (
"log"
@@ -21,6 +21,10 @@ func Log(ip, uuid, message, msgType string) {
db.Create(logEntry)
}
func EventLog(eventType, message string) {
Log("", "", message, eventType)
}
// Delete logs older than 30 days
func RemoveOldLogs() {
db := dbcore.GetDBInstance()
+93 -109
View File
@@ -1,29 +1,21 @@
package clients
import (
"encoding/json"
"fmt"
"log"
"math"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fmt"
"github.com/google/uuid"
)
// Deprecated: DeleteClientConfig is deprecated and will be removed in a future release. Use DeleteClient instead.
func DeleteClientConfig(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&common.ClientConfig{ClientUUID: clientUuid}).Error
if err != nil {
return err
}
return nil
}
func DeleteClient(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
@@ -33,79 +25,6 @@ func DeleteClient(clientUuid string) error {
return nil
}
// Deprecated: UpdateOrInsertBasicInfo is deprecated and will be removed in a future release. Use SaveClientInfo instead.
func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
db := dbcore.GetDBInstance()
updates := make(map[string]interface{})
if cbi.Name != "" {
updates["name"] = cbi.Name
}
if cbi.CpuName != "" {
updates["cpu_name"] = cbi.CpuName
}
if cbi.Arch != "" {
updates["arch"] = cbi.Arch
}
if cbi.CpuCores != 0 {
updates["cpu_cores"] = cbi.CpuCores
}
if cbi.OS != "" {
updates["os"] = cbi.OS
}
if cbi.GpuName != "" {
updates["gpu_name"] = cbi.GpuName
}
if cbi.IPv4 != "" {
updates["ipv4"] = cbi.IPv4
}
if cbi.IPv6 != "" {
updates["ipv6"] = cbi.IPv6
}
if cbi.Region != "" {
updates["region"] = cbi.Region
}
if cbi.Remark != "" {
updates["remark"] = cbi.Remark
}
updates["mem_total"] = cbi.MemTotal
updates["swap_total"] = cbi.SwapTotal
updates["disk_total"] = cbi.DiskTotal
updates["version"] = cbi.Version
updates["updated_at"] = time.Now()
// 转换为更新Client表
client := models.Client{
UUID: cbi.UUID,
}
err := db.Model(&client).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(updates),
}).Create(map[string]interface{}{
"uuid": cbi.UUID,
"name": cbi.Name,
"cpu_name": cbi.CpuName,
"arch": cbi.Arch,
"cpu_cores": cbi.CpuCores,
"os": cbi.OS,
"gpu_name": cbi.GpuName,
"ipv4": cbi.IPv4,
"ipv6": cbi.IPv6,
"region": cbi.Region,
"remark": cbi.Remark,
"mem_total": cbi.MemTotal,
"swap_total": cbi.SwapTotal,
"disk_total": cbi.DiskTotal,
"version": cbi.Version,
"updated_at": time.Now(),
}).Error
if err != nil {
return err
}
return nil
}
func SaveClientInfo(update map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := update["uuid"].(string)
@@ -120,17 +39,83 @@ func SaveClientInfo(update map[string]interface{}) error {
update["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
toFloat64 := func(value interface{}) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int8:
return float64(typed), true
case int16:
return float64(typed), true
case int32:
return float64(typed), true
case int64:
return float64(typed), true
case uint:
return float64(typed), true
case uint8:
return float64(typed), true
case uint16:
return float64(typed), true
case uint32:
return float64(typed), true
case uint64:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
if err != nil {
return 0, false
}
return parsed, true
default:
return 0, false
}
}
checkOptionalInt := func(name, key string, maxValue float64) error {
value, exists := update[key]
if !exists || value == nil {
return nil
}
numericValue, ok := toFloat64(value)
if !ok {
return fmt.Errorf("%s must be a valid number", name)
}
if numericValue < 0 || numericValue > maxValue {
return fmt.Errorf("%s must be a valid non-negative number: %v", name, value)
}
return nil
}
verify := func(update map[string]interface{}) error {
if err := checkOptionalInt("Cpu.Cores", "cpu_cores", math.MaxInt-1); err != nil {
return err
}
if err := checkOptionalInt("Cpu.PhysicalCores", "cpu_physical_cores", math.MaxInt-1); err != nil {
return err
}
if err := checkOptionalInt("Ram.Total", "mem_total", math.MaxInt64-1); err != nil {
return err
}
if err := checkOptionalInt("Swap.Total", "swap_total", math.MaxInt64-1); err != nil {
return err
}
if err := checkOptionalInt("Disk.Total", "disk_total", math.MaxInt64-1); err != nil {
return err
}
return nil
}
if err := verify(update); err != nil {
return err
}
return nil
}
// 更新客户端设置
func UpdateClientConfig(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
err := db.Save(&config).Error
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
}
@@ -146,21 +131,6 @@ func EditClientName(clientUUID, clientName string) error {
return nil
}
/*
// UpdateClientByUUID 更新指定 UUID 的客户端配置
func UpdateClientByUUID(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
result := db.Model(&common.ClientConfig{}).Where("client_uuid = ?", config.ClientUUID).Updates(config)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
*/
func EditClientToken(clientUUID, token string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
@@ -188,6 +158,9 @@ func CreateClient() (clientUUID, token string, err error) {
if err != nil {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
log.Println("Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
@@ -210,6 +183,9 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
if err != nil {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
log.Println("Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
@@ -278,6 +254,14 @@ func SaveClient(updates map[string]interface{}) error {
return fmt.Errorf("no fields to update")
}
if v, exists := updates["traffic_limit"]; exists {
if val, ok := v.(float64); ok {
if val < 0 || val > math.MaxInt64-1 {
return fmt.Errorf("traffic_limit must be a valid non-negative int64 value, got %v", val)
}
}
}
updates["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
+179 -8
View File
@@ -2,16 +2,58 @@ package clients
import (
"encoding/json"
"errors"
"fmt"
"math"
"sync"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
v1 "github.com/komari-monitor/komari/protocol/v1"
"gorm.io/gorm"
)
var reportSaveLocks sync.Map
func getReportSaveLock(clientUUID string) *sync.Mutex {
lock, _ := reportSaveLocks.LoadOrStore(clientUUID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func computeTrafficDelta(current, previous int64) int64 {
if previous < 0 {
return 0
}
if current >= previous {
return current - previous
}
// Counter reset or reboot: treat the new counter value as traffic since reset.
return current
}
func getLatestTrafficRecord(tx *gorm.DB, clientUUID string) (*models.Record, error) {
var record models.Record
err := tx.Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
if err == nil {
return &record, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
err = tx.Table("records_long_term").Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
if err == nil {
return &record, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return nil, nil
}
// Report 表示客户端报告数据
// SaveReport 保存报告数据
func SaveReport(uuid string, data map[string]interface{}) (err error) {
@@ -39,27 +81,144 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
return client.UUID, nil
}
func ParseReport(data map[string]interface{}) (report common.Report, err error) {
func ParseReport(data map[string]interface{}) (report v1.Report, err error) {
jsonData, err := json.Marshal(data)
if err != nil {
return common.Report{}, err
return v1.Report{}, err
}
err = json.Unmarshal(jsonData, &report)
if err != nil {
return common.Report{}, err
return v1.Report{}, err
}
return report, nil
}
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report v1.Report) error {
// 防止输入不合理范围
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
return fmt.Errorf("CPU.Usage must be between 0 and 100")
}
if report.Load.Load1 < 0 || report.Load.Load1 > 1000 {
return fmt.Errorf("Load.Load1 must be non-negative, got %.2f", report.Load.Load1)
}
checkFloat64 := func(name string, val float64) error {
if val > math.MaxFloat64-1 || val < -math.MaxFloat64+1 {
return fmt.Errorf("%s value exceeds float64 range: %g", name, val)
}
return nil
}
// [float64] 防止数据溢出
if err := checkFloat64("CPU.Usage", report.CPU.Usage); err != nil {
return err
}
if err := checkFloat64("Load.Load1", report.Load.Load1); err != nil {
return err
}
checkInt64 := func(name string, val int64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, val)
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, val)
}
return nil
}
// [int64] 防止数据溢出
// Ram 验证
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
return err
}
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
return err
}
// Swap 验证
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
return err
}
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
return err
}
// Disk 验证
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
return err
}
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
return err
}
// Network 验证
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
return err
}
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
return err
}
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
return err
}
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
return err
}
// 拒绝所有负数Int
if report.Process < 0 {
return fmt.Errorf("Process must be non-negative: %d", report.Process)
}
if report.Connections.TCP < 0 {
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
}
if report.Connections.UDP < 0 {
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
}
return nil
}
// SaveClientReport 保存客户端报告到 Record 表
func SaveClientReport(clientUUID string, report common.Report) (err error) {
func SaveClientReport(clientUUID string, report v1.Report) (err error) {
db := dbcore.GetDBInstance()
lock := getReportSaveLock(clientUUID)
lock.Lock()
defer lock.Unlock()
if err := ReportVerify(report); err != nil {
return fmt.Errorf("failed to save Record: %w", err)
}
// 保存GPU详细记录到独立表
currentTime := time.Now()
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
for idx, gpu := range report.GPU.DetailedInfo {
gpuRecord := models.GPURecord{
Client: clientUUID,
Time: models.FromTime(currentTime),
DeviceIndex: idx,
DeviceName: gpu.Name,
MemTotal: gpu.MemoryTotal,
MemUsed: gpu.MemoryUsed,
Utilization: float32(gpu.Utilization),
Temperature: gpu.Temperature,
}
if err := db.Create(&gpuRecord).Error; err != nil {
return fmt.Errorf("failed to save GPU record: %w", err)
}
}
}
// 计算平均GPU使用率,用于向后兼容
averageGPUUsage := float32(0)
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
averageGPUUsage = float32(report.GPU.AverageUsage)
}
Record := models.Record{
Client: clientUUID,
Time: models.FromTime(time.Now()),
Time: models.FromTime(currentTime),
Cpu: float32(report.CPU.Usage),
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
Gpu: averageGPUUsage, // 使用平均GPU使用率
Ram: report.Ram.Used,
RamTotal: report.Ram.Total,
Swap: report.Swap.Used,
@@ -72,16 +231,28 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
NetOut: report.Network.Up,
NetTotalUp: report.Network.TotalUp,
NetTotalDown: report.Network.TotalDown,
TrafficUp: 0,
TrafficDown: 0,
Process: report.Process,
Connections: report.Connections.TCP,
ConnectionsUdp: report.Connections.UDP,
//Uptime: report.Uptime,
}
// 使用事务确保 Record 和 ClientsInfo 一致性
err = db.Transaction(func(tx *gorm.DB) error {
previous, err := getLatestTrafficRecord(tx, clientUUID)
if err != nil {
return fmt.Errorf("failed to load previous Record: %w", err)
}
if previous != nil {
Record.TrafficUp = computeTrafficDelta(report.Network.TotalUp, previous.NetTotalUp)
Record.TrafficDown = computeTrafficDelta(report.Network.TotalDown, previous.NetTotalDown)
}
// 保存 Record
if err := tx.Create(&Record).Error; err != nil {
return fmt.Errorf("failed to save Record: %v", err)
return fmt.Errorf("failed to save Record: %w", err)
}
return nil
})
+82
View File
@@ -0,0 +1,82 @@
package clients
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestComputeTrafficDeltaHandlesZeroAndReset(t *testing.T) {
tests := []struct {
name string
current int64
previous int64
want int64
}{
{name: "previous zero counts current delta", current: 120, previous: 0, want: 120},
{name: "monotonic counter uses difference", current: 250, previous: 200, want: 50},
{name: "counter reset uses current", current: 15, previous: 250, want: 15},
{name: "negative previous remains guarded", current: 15, previous: -1, want: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, computeTrafficDelta(test.current, test.previous))
})
}
}
func TestReportSaveLockSerializesSameClient(t *testing.T) {
clientUUID := "client-lock-same"
lock := getReportSaveLock(clientUUID)
lock.Lock()
defer lock.Unlock()
acquired := make(chan struct{})
go func() {
secondLock := getReportSaveLock(clientUUID)
secondLock.Lock()
defer secondLock.Unlock()
close(acquired)
}()
select {
case <-acquired:
t.Fatal("same-client report save lock was acquired while already held")
case <-time.After(20 * time.Millisecond):
}
lock.Unlock()
select {
case <-acquired:
case <-time.After(time.Second):
t.Fatal("same-client report save lock was not released")
}
lock.Lock()
}
func TestReportSaveLockAllowsDifferentClients(t *testing.T) {
firstLock := getReportSaveLock("client-lock-a")
firstLock.Lock()
defer firstLock.Unlock()
var waitGroup sync.WaitGroup
waitGroup.Add(1)
acquired := make(chan struct{})
go func() {
defer waitGroup.Done()
secondLock := getReportSaveLock("client-lock-b")
secondLock.Lock()
defer secondLock.Unlock()
close(acquired)
}()
select {
case <-acquired:
case <-time.After(time.Second):
t.Fatal("different-client report save lock should not block")
}
waitGroup.Wait()
}
-113
View File
@@ -1,113 +0,0 @@
package config
import (
"errors"
"log"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm"
)
func Get() (models.Config, error) {
db := dbcore.GetDBInstance()
var config models.Config
if err := db.First(&config).Error; err != nil {
if err == gorm.ErrRecordNotFound {
config = models.Config{
ID: 1,
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "ip-api",
EmailPort: 587,
EmailUseSSL: true,
EmailReceiver: "", // Default empty
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)
}
return config, nil
}
return config, err
}
return config, nil
}
func Save(cst models.Config) error {
db := dbcore.GetDBInstance()
oldConfig, _ := Get()
// Only one records
cst.ID = 1
cst.UpdatedAt = models.FromTime(time.Now())
// Do not update CreatedAt
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",
"email_enabled",
"email_host",
"email_port",
"email_username",
"email_password",
"email_sender",
"email_use_ssl",
"email_receiver",
"updated_at").
Updates(cst).Error; err != nil {
return err
}
newConfig, _ := Get()
publishEvent(oldConfig, newConfig)
return nil
}
func Update(cst map[string]interface{}) error {
db := dbcore.GetDBInstance()
oldConfig, _ := Get()
// Proceed with update
cst["id"] = 1
cst["updated_at"] = time.Now()
delete(cst, "created_at")
delete(cst, "CreatedAt")
// 至少有一种登录方式启用
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
newOAuthEnabled := oldConfig.OAuthEnabled
if val, exists := cst["disable_password_login"]; exists {
newDisablePasswordLogin = val.(bool)
}
if val, exists := cst["o_auth_enabled"]; exists {
newOAuthEnabled = val.(bool)
}
if newDisablePasswordLogin && !newOAuthEnabled {
return errors.New("at least one login method must be enabled (password/oauth)")
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
return errors.Join(err, errors.New("failed to update configuration"))
}
newConfig := &models.Config{}
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
}
publishEvent(oldConfig, *newConfig)
return nil
})
if err != nil {
return err
}
return nil
}
-38
View File
@@ -1,38 +0,0 @@
package config
import (
"sync"
"github.com/komari-monitor/komari/database/models"
)
// ConfigEvent represents a configuration change event
type ConfigEvent struct {
Old models.Config
New models.Config
}
// ConfigSubscriber handles config events
type ConfigSubscriber func(event ConfigEvent)
var (
subscribersMu sync.RWMutex
subscribers []ConfigSubscriber
)
// Subscribe registers a subscriber for all config events.
func Subscribe(subscriber ConfigSubscriber) {
subscribersMu.Lock()
defer subscribersMu.Unlock()
subscribers = append(subscribers, subscriber)
}
// publishEvent notifies all subscribers of a config change.
func publishEvent(oldVal, newVal models.Config) {
subscribersMu.RLock()
defer subscribersMu.RUnlock()
event := ConfigEvent{Old: oldVal, New: newVal}
for _, sub := range subscribers {
go sub(event)
}
}
+222 -79
View File
@@ -1,80 +1,168 @@
package dbcore
import (
"archive/zip"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"gorm.io/driver/mysql"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/migrations"
logutil "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
func mergeClientInfo(db *gorm.DB) {
var clientInfos []common.ClientInfo
if err := db.Find(&clientInfos).Error; err != nil {
log.Printf("Failed to read ClientInfo table: %v", err)
return
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
func zipDirectoryExcluding(srcDir, dstZip string, exclude map[string]struct{}) error {
// 规范化排除路径为绝对路径
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
for _, info := range clientInfos {
var client models.Client
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
continue
out, err := os.Create(dstZip)
if err != nil {
return err
}
defer out.Close()
zw := zip.NewWriter(out)
defer zw.Close()
absSrc, _ := filepath.Abs(srcDir)
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 更新Client记录
client.Name = info.Name
client.CpuName = info.CpuName
client.Virtualization = info.Virtualization
client.Arch = info.Arch
client.CpuCores = info.CpuCores
client.OS = info.OS
client.GpuName = info.GpuName
client.IPv4 = info.IPv4
client.IPv6 = info.IPv6
client.Region = info.Region
client.Remark = info.Remark
client.PublicRemark = info.PublicRemark
client.MemTotal = info.MemTotal
client.SwapTotal = info.SwapTotal
client.DiskTotal = info.DiskTotal
client.Version = info.Version
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
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)
continue
// 排除 backup.zip 本身
if _, ok := normExclude[path]; ok {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}
// 计算 zip 内相对路径
rel, err := filepath.Rel(absSrc, path)
if err != nil {
return err
}
// 根目录跳过
if rel == "." {
return nil
}
// 替换为正斜杠
zipName := filepath.ToSlash(rel)
// Backup and rename old table after migration
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
log.Printf("Failed to backup ClientInfo table: %v", err)
return
if info.IsDir() {
_, err := zw.Create(zipName + "/")
return err
}
// 普通文件
fh, err := os.Open(path)
if err != nil {
return err
}
w, err := zw.Create(zipName)
if err != nil {
fh.Close()
return err
}
if _, err := io.Copy(w, fh); err != nil {
fh.Close()
return err
}
fh.Close()
return nil
})
if walkErr != nil {
return walkErr
}
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
return zw.Close()
}
func MergeDatabase(db *gorm.DB) {
if db.Migrator().HasTable("client_infos") {
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
mergeClientInfo(db)
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}
if db.Migrator().HasColumn(&models.Config{}, "allow_cros") {
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
entries, err := os.ReadDir(absDir)
if err != nil {
return err
}
for _, e := range entries {
full := filepath.Join(absDir, e.Name())
if _, ok := normExclude[full]; ok {
continue
}
if err := os.RemoveAll(full); err != nil {
return err
}
}
return nil
}
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
func unzipToDir(zipPath, dstDir string) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
if err := os.MkdirAll(dstDir, 0755); err != nil {
return err
}
absDst, _ := filepath.Abs(dstDir)
for _, f := range zr.File {
// 构造目标路径并做路径遍历保护
cleanName := filepath.Clean(f.Name)
targetPath := filepath.Join(absDst, cleanName)
if !strings.HasPrefix(targetPath, absDst+string(os.PathSeparator)) && targetPath != absDst {
return fmt.Errorf("illegal file path in zip: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(targetPath)
if err != nil {
rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
rc.Close()
return err
}
out.Close()
rc.Close()
}
return nil
}
var (
@@ -84,58 +172,97 @@ var (
func GetDBInstance() *gorm.DB {
once.Do(func() {
var err error
logConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
func() {
backupZipPath := filepath.Join(".", "data", "backup.zip")
if _, statErr := os.Stat(backupZipPath); statErr == nil {
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
if err := os.MkdirAll("./backup", 0755); err != nil {
log.Printf("[restore] failed to create backup dir: %v", err)
} else {
tsName := time.Now().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
log.Printf("[restore] failed to zip current data: %v", zipErr)
} else {
log.Printf("[restore] current data zipped to %s", bakPath)
}
}
if utils.VersionHash == "unknown" {
logConfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
// 5. 删除除了 ./data/backup.zip 之外的所有文件
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
}
// 6. 解压 ./data/backup.zip 到 ./data
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
} else {
log.Printf("[restore] backup.zip extracted to ./data")
}
// 7. 删除 ./data/backup.zip
if rmErr := os.Remove(backupZipPath); rmErr != nil {
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
} else {
log.Printf("[restore] backup.zip removed")
}
// 8. 删除标记
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
} else {
log.Printf("[restore] komari-backup-markup removed")
}
}
}()
logConfig := &gorm.Config{
Logger: logutil.NewGormLogger(),
}
// 根据数据库类型选择不同的连接方式
switch flags.DatabaseType {
case "sqlite", "":
switch flags.ApplyDatabaseTypeNormalization() {
case flags.DatabaseTypeSQLite:
// SQLite 连接
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
if err != nil {
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
}
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
case "mysql":
// MySQL 连接
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
flags.DatabaseUser,
flags.DatabasePass,
flags.DatabaseHost,
flags.DatabasePort,
flags.DatabaseName)
instance, err = gorm.Open(mysql.Open(dsn), logConfig)
if err != nil {
log.Fatalf("Failed to connect to MySQL database: %v", err)
instance.Exec("PRAGMA wal = ON;")
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
}
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
instance.Exec("PRAGMA synchronous = NORMAL;")
instance.Exec("PRAGMA cache_size = -65536;")
instance.Exec("PRAGMA temp_store = MEMORY;")
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
default:
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
log.Fatalf("Unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
}
MergeDatabase(instance)
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
log.Fatalf("Failed to run startup migrations: %v", err)
}
config.SetDb(instance)
// 自动迁移模型
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Record{},
&models.Config{},
&models.GPURecord{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.TrafficReportNotification{},
&models.PingRecord{},
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.ThemeConfiguration{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
@@ -146,6 +273,12 @@ func GetDBInstance() *gorm.DB {
if err != nil {
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
}
err = instance.Table("gpu_records_long_term").AutoMigrate(
&models.GPURecord{},
)
if err != nil {
log.Printf("Failed to create gpu_records_long_term table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
@@ -160,6 +293,16 @@ func GetDBInstance() *gorm.DB {
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
}
// Manually create composite indexes
if flags.IsSQLite() {
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_client_time ON records(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_lt_client_time ON records_long_term(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_client_time ON gpu_records(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_lt_client_time ON gpu_records_long_term(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_ping_record_client_time ON ping_records(client, time)")
}
})
return instance
}
+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
}
+71 -95
View File
@@ -10,136 +10,112 @@ import (
"time"
)
// UTCTime 是一个自定义类型,用于在 GORM 中处理 time.Time。
// 它强制所有时间都以指定时区(或 UTC)的字符串格式存取。
type UTCTime time.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 实现 driver.Valuer 接口,将 UTCTime 转换为数据库可以识别的 TEXT 类型。
// 所有时间在存储到数据库前,都转换为 UTC 并以标准格式存储。
func (t UTCTime) Value() (driver.Value, error) {
// 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 // GORM 默认会将零值时间存储为 NULL,这里保持一致
return nil, nil
}
// 将时间转换为应用程序指定的时区,并格式化为不带时区信息的字符串。
// 这样数据库中存储的是 'YYYY-MM-DD HH:MM:SS.NNNNNNN' 格式。
// 注意:之前你遇到的格式是 "2025-06-14 23:51:17.3022328+08:00"
// 这种格式带有微秒和时区偏移。为了兼容旧数据读取,我们这里也用精确格式。
// 但存储时,为了避免时区混乱,建议统一为某个特定时区的纯时间戳字符串。
// 这里我建议统一存储为 UTC 时间的精确字符串。
// "2006-01-02 15:04:05.0000000" 这个布局对应不带时区后缀的纳秒精度。
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
}
// Scan 实现 sql.Scanner 接口,将数据库中的 TEXT 值扫描到 UTCTime。
// 所有时间字符串在读取时,都会被尝试解析为指定时区(或 UTC)的 time.Time 对象。
func (t *UTCTime) Scan(v interface{}) error {
// 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 = UTCTime(time.Time{})
*t = LocalTime(time.Time{})
return nil
}
loc := GetAppLocation()
switch val := v.(type) {
case time.Time:
// If the driver already parsed it to time.Time, use it directly.
// Then convert to the application's desired location.
*t = UTCTime(val.In(GetAppLocation()))
// 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:
// If it's still a byte slice (string), parse it.
timeStr := strings.TrimSpace(string(val))
if timeStr == "" {
*t = UTCTime(time.Time{})
return nil
}
var parsedTime time.Time
var err error
layouts := []string{
"2006-01-02 15:04:05.0000000-07:00", // Your observed precise format with timezone
"2006-01-02 15:04:05-07:00", // With seconds and timezone
time.RFC3339Nano, // RFC3339 with nanoseconds
time.RFC3339, // RFC3339
"2006-01-02 15:04:05.0000000", // Nanoseconds, no timezone
"2006-01-02 15:04:05", // Standard, no timezone
"2006-01-02", // Date only
}
for _, layout := range layouts {
// time.Parse handles strings that include timezone info correctly.
parsedTime, err = time.Parse(layout, timeStr)
if err == nil {
break
}
}
if err != nil {
return fmt.Errorf("无法解析时间字符串 '%s' 为 UTCTime: %w", timeStr, err)
}
*t = UTCTime(parsedTime.In(GetAppLocation()))
return nil
return t.parseTime(string(val), loc)
case string:
return t.parseTime(val, loc)
default:
return fmt.Errorf("UTCTime scan source was not []byte or time.Time: %T (%v)", v, v)
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
}
}
// MarshalJSON implements json.Marshaler interface.
// This method defines how UTCTime should be serialized to JSON.
func (t UTCTime) MarshalJSON() ([]byte, error) {
// 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() {
// 如果是零值时间,返回 JSON null。
// 这样 API 返回的就是 "field": null,而不是 "field": {}。
return []byte("null"), nil
}
// 将时间转换为应用程序指定的时区,并格式化为 JSON 字符串。
// 使用双引号包裹,符合 JSON 字符串规范。
// 同样使用之前定义的精确格式,便于客户端解析。
formattedTime := time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000-07:00")
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
}
// ToTime 将 UTCTime 转换为 Go 的 time.Time 类型。
func (t UTCTime) ToTime() time.Time {
return time.Time(t)
}
// ToTime converts UTCTime to Go's native time.Time type.
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
// FromTime 将 Go 的 time.Time 类型转换为 UTCTime。
func FromTime(t time.Time) UTCTime {
return UTCTime(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() UTCTime {
return UTCTime(time.Now().In(GetAppLocation()))
}
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
// AppLocation 用于存储应用程序全局使用的时区。
// 首次获取后会缓存,避免重复加载。
var (
appLocation *time.Location
locationOnce sync.Once
defaultLocation = time.UTC // 默认时区设为 UTC
appLocation *time.Location
locationOnce sync.Once
)
// GetAppLocation 获取应用程序的全局时区。
// 优先从环境变量 "TZ" 获取,否则使用默认的 UTC。
// 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 != "" {
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("Warning: Failed to load timezone from environment variable TZ='%s', using default UTC. Error: %v\n", tz, err)
appLocation = defaultLocation
} else {
appLocation = loc
log.Printf("Info: Application timezone set from environment variable TZ='%s' (%s).\n", tz, appLocation.String())
}
} else {
appLocation = defaultLocation
log.Printf("Info: Environment variable TZ not set, using default UTC timezone (%s).\n", appLocation.String())
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
}
+7 -7
View File
@@ -1,11 +1,11 @@
package models
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 UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
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 LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
-43
View File
@@ -1,43 +0,0 @@
package models
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// 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"`
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)"`
// Email 配置
EmailHost string `json:"email_host" gorm:"type:varchar(255)"` // 邮件服务器地址
EmailPort int `json:"email_port" gorm:"default:587"` // 邮件服务器端口,默认587
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)"` // 邮箱发送者地址
EmailUseSSL bool `json:"email_use_ssl" gorm:"default:true"` // 是否使用 SSL/TLS 连接,默认 true
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"` // 邮件接收者地址
// 通知
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:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt UTCTime
UpdatedAt UTCTime
}
+6 -6
View File
@@ -1,10 +1,10 @@
package models
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 UTCTime `json:"time" gorm:"autoCreateTime;not null"`
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 LocalTime `json:"time" gorm:"autoCreateTime;not null"`
}
@@ -0,0 +1,14 @@
package messageevent
const (
Offline = "Offline"
Online = "Online"
Expire = "Expire"
Renew = "Renew"
Login = "Login"
Alert = "Alert"
Traffic = "Traffic"
DReport = "DReport" // 日报
WReport = "WReport" // 周报
MReport = "MReport" // 月报
)
+16
View File
@@ -0,0 +1,16 @@
package models
import "time"
type MessageSenderProvider struct {
Name string `json:"name" gorm:"primaryKey;unique;not null"`
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
}
type EventMessage struct {
Event string `json:"event"`
Clients []Client `json:"clients"`
Time time.Time `json:"time"`
Message string `json:"message"`
Emoji string `json:"emoji"`
}
+101 -65
View File
@@ -8,80 +8,104 @@ import (
// Client represents a registered client device
type Client struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt UTCTime `json:"expired_at" gorm:"type:timestamp"`
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
CpuPhysicalCores int `json:"cpu_physical_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
Hidden bool `json:"hidden" gorm:"default:false"`
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// User represents an authenticated user
type User struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Username string `json:"username" gorm:"type:varchar(50);unique;not null"`
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
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 UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Username string `json:"username" gorm:"type:varchar(50);unique;not null"`
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
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
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"`
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 UTCTime `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 UTCTime `json:"expires" gorm:"not null"`
CreatedAt UTCTime `json:"created_at"`
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 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 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 UTCTime `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"`
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
Swap int64 `json:"swap" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
Disk int64 `json:"disk" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
NetIn int64 `json:"net_in" gorm:"type:bigint"`
NetOut int64 `json:"net_out" gorm:"type:bigint"`
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
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"`
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
Swap int64 `json:"swap" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
Disk int64 `json:"disk" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
NetIn int64 `json:"net_in" gorm:"type:bigint"`
NetOut int64 `json:"net_out" gorm:"type:bigint"`
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
TrafficUp int64 `json:"traffic_up" gorm:"type:bigint"`
TrafficDown int64 `json:"traffic_down" gorm:"type:bigint"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
//Uptime int64 `json:"uptime" gorm:"type:bigint"`
}
// GPURecord logs individual GPU metrics over time
type GPURecord struct {
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
Time LocalTime `json:"time" gorm:"index"` // 记录时间
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// StringArray represents a slice of strings stored as JSON in the database
@@ -89,9 +113,21 @@ type Record struct {
type StringArray []string
func (sa *StringArray) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to scan StringArray: value is not []byte")
var bytes []byte
switch v := value.(type) {
case nil:
*sa = StringArray{}
return nil
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return fmt.Errorf("failed to scan StringArray: unsupported value type %T", value)
}
if len(bytes) == 0 {
*sa = StringArray{}
return nil
}
return json.Unmarshal(bytes, sa)
}
+30
View File
@@ -0,0 +1,30 @@
package models
import "testing"
func TestStringArrayScanAcceptsSQLiteTextShapes(t *testing.T) {
tests := map[string]interface{}{
"bytes": []byte(`["a","b"]`),
"text": `["a","b"]`,
"empty": "",
"nil": nil,
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
var got StringArray
if err := got.Scan(input); err != nil {
t.Fatalf("scan StringArray: %v", err)
}
if input == "" || input == nil {
if len(got) != 0 {
t.Fatalf("expected empty StringArray, got %v", got)
}
return
}
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected StringArray: %v", got)
}
})
}
}
+23 -12
View File
@@ -2,21 +2,32 @@ package models
// 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 UTCTime `json:"last_notified"` // 上次通知时间
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
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 UTCTime `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"` // 上次通知时间
}
// TrafficReportNotification 定义了流量定时报告的数据库模型
type TrafficReportNotification struct {
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"`
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
Monthly bool `json:"monthly" gorm:"type:boolean;default:false"` // 月报
}
+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:"{}"`
}
+28 -10
View File
@@ -1,17 +1,35 @@
package models
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 UTCTime `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 值,单位毫秒
}
// PingTask 表示一次延迟监测任务配置。
type PingTask struct {
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
Target string `json:"target" gorm:"type:varchar(255);not null"`
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Weight int `json:"weight" gorm:"type:int;not null;default:0;index"`
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
DefaultOn bool `json:"default_on" gorm:"column:all_clients;not null;default:false"` // 新加入的服务器是否自动开启此监测;现有服务器不受此字段影响
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
Target string `json:"target" gorm:"type:varchar(255);not null"` // Ping 目标地址
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
}
// AppliesToClient 判断当前 PingTask 是否适用于指定服务器。
func (task PingTask) AppliesToClient(uuid string) bool {
if uuid == "" {
return false
}
for _, client := range task.Clients {
if client == uuid {
return true
}
}
return false
}
+12 -9
View File
@@ -1,15 +1,18 @@
package models
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)"`
Client string `json:"client" gorm:"type:varchar(36)"`
Result string `json:"result" gorm:"type:longtext"`
ExitCode *int `json:"exit_code" gorm:"type:int"`
FinishedAt *UTCTime `json:"finished_at" gorm:"type:timestamp"`
CreatedAt UTCTime `json:"created_at" gorm:"type:timestamp"`
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 *LocalTime `json:"finished_at" gorm:"type:timestamp"`
CreatedAt LocalTime `json:"created_at" gorm:"type:timestamp"`
}
+146
View File
@@ -0,0 +1,146 @@
package models
import (
"fmt"
"net/url"
"path"
"strings"
)
const (
ThemeConfigurationManaged = "managed"
ThemeConfigurationRaw = "raw"
ThemeConfigurationRedirect = "redirect"
)
// 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"` // 预览图片相对路径
Configuration Configuration `json:"configuration"` // 声明配置项
}
type Configuration struct {
Type string `json:"type"` // managed raw redirect
Icon string `json:"icon"` // 图标
Name any `json:"name"`
Data any `json:"data"` // 配置数据
}
type ManagedThemeConfigurationItem struct {
Key string `json:"key"`
Name any `json:"name"`
Required bool `json:"required"`
Type string `json:"type"` // string number select switch title
Options string `json:"options"`
Default any `json:"default"`
Help any `json:"help"`
}
type ThemeConfiguration struct {
Short string `json:"short" gorm:"primaryKey;unique;not null"`
Data string `json:"data" gorm:"type:longtext" default:"{}"`
}
func (t Theme) ConfigurationType() string {
typ := strings.ToLower(strings.TrimSpace(t.Configuration.Type))
if typ == "" {
return ThemeConfigurationManaged
}
return typ
}
func (t Theme) RawHTML() (string, bool) {
if t.ConfigurationType() != ThemeConfigurationRaw {
return "", false
}
return configurationDataString(t.Configuration.Data)
}
func (t Theme) RedirectTarget() (string, bool) {
if t.ConfigurationType() != ThemeConfigurationRedirect {
return "", false
}
return NormalizeThemeRedirectTarget(t.Configuration.Data)
}
func (t Theme) ValidateConfiguration() error {
switch t.ConfigurationType() {
case ThemeConfigurationManaged:
return nil
case ThemeConfigurationRaw:
html, ok := t.RawHTML()
if !ok || strings.TrimSpace(html) == "" {
return fmt.Errorf("raw 类型主题需要在 configuration.data 中提供 HTML 字符串")
}
return nil
case ThemeConfigurationRedirect:
if _, ok := t.RedirectTarget(); !ok {
return fmt.Errorf("redirect 类型主题需要在 configuration.data 中提供站内相对路径")
}
return nil
default:
return fmt.Errorf("不支持的主题类型: %s", t.Configuration.Type)
}
}
func NormalizeThemeRedirectTarget(data any) (string, bool) {
target, ok := configurationDataString(data)
if !ok {
return "", false
}
target = strings.TrimSpace(target)
if target == "" || strings.Contains(target, "\\") || strings.HasPrefix(target, "//") {
return "", false
}
parsed, err := url.Parse(target)
if err != nil || parsed.IsAbs() || parsed.Host != "" {
return "", false
}
cleanInputPath := parsed.Path
if strings.HasPrefix(cleanInputPath, "/") {
cleanInputPath = strings.TrimLeft(cleanInputPath, "/")
} else {
for strings.HasPrefix(cleanInputPath, "../") {
cleanInputPath = strings.TrimPrefix(cleanInputPath, "../")
}
}
for _, segment := range strings.Split(cleanInputPath, "/") {
if segment == ".." {
return "", false
}
}
cleanPath := cleanInputPath
if cleanPath == "" {
cleanPath = "/"
} else {
cleanPath = path.Clean(cleanPath)
if cleanPath == "." {
cleanPath = "/"
} else {
cleanPath = "/" + strings.TrimPrefix(cleanPath, "/")
}
}
normalized := url.URL{
Path: cleanPath,
RawQuery: parsed.RawQuery,
Fragment: parsed.Fragment,
}
return normalized.String(), true
}
func configurationDataString(data any) (string, bool) {
value, ok := data.(string)
return value, ok
}
+141
View File
@@ -0,0 +1,141 @@
package models
import "testing"
func TestThemeValidateConfiguration(t *testing.T) {
tests := map[string]struct {
theme Theme
wantErr bool
}{
"managed empty data": {
theme: Theme{
Configuration: Configuration{Type: ThemeConfigurationManaged},
},
},
"missing type defaults to managed": {
theme: Theme{},
},
"raw html": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRaw,
Data: "<!doctype html><title>raw</title>",
},
},
},
"raw rejects empty html": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRaw,
Data: " ",
},
},
wantErr: true,
},
"redirect relative path": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRedirect,
Data: "/dashboard?tab=nodes",
},
},
},
"redirect rejects absolute url": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRedirect,
Data: "https://example.com",
},
},
wantErr: true,
},
"redirect rejects parent traversal": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRedirect,
Data: "/../admin",
},
},
wantErr: true,
},
"unknown type": {
theme: Theme{
Configuration: Configuration{Type: "hosted"},
},
wantErr: true,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
err := tt.theme.ValidateConfiguration()
if tt.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestNormalizeThemeRedirectTarget(t *testing.T) {
tests := map[string]struct {
input string
want string
ok bool
}{
"leading slash": {
input: "/nodes",
want: "/nodes",
ok: true,
},
"missing leading slash": {
input: "nodes",
want: "/nodes",
ok: true,
},
"parent prefix points to root path": {
input: "../settings",
want: "/settings",
ok: true,
},
"multiple parent prefixes point to root path": {
input: "../../settings/site",
want: "/settings/site",
ok: true,
},
"query and fragment": {
input: "nodes?id=1#cpu",
want: "/nodes?id=1#cpu",
ok: true,
},
"absolute parent traversal": {
input: "/../admin",
},
"nested parent traversal": {
input: "nodes/../admin",
},
"protocol relative": {
input: "//example.com",
},
"backslash": {
input: `\admin`,
},
"absolute url": {
input: "https://example.com",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got, ok := NormalizeThemeRedirectTarget(tt.input)
if ok != tt.ok {
t.Fatalf("ok = %v, want %v", ok, tt.ok)
}
if got != tt.want {
t.Fatalf("target = %q, want %q", got, tt.want)
}
})
}
}
+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
+144
View File
@@ -0,0 +1,144 @@
package notification
import (
"fmt"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm/clause"
)
func validateTrafficReportNotification(notification models.TrafficReportNotification) error {
if notification.Client == "" {
return fmt.Errorf("client UUID cannot be empty")
}
if notification.Enable && !notification.Daily && !notification.Weekly && !notification.Monthly {
return fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
}
return nil
}
func ValidateTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
for _, notification := range notifications {
if err := validateTrafficReportNotification(notification); err != nil {
return err
}
}
return nil
}
func buildEnabledTrafficReportNotifications(uuids []string, existing []models.TrafficReportNotification) ([]models.TrafficReportNotification, error) {
if len(uuids) == 0 {
return nil, fmt.Errorf("at least one client UUID is required")
}
existingByClient := make(map[string]models.TrafficReportNotification, len(existing))
for _, notification := range existing {
existingByClient[notification.Client] = notification
}
notifications := make([]models.TrafficReportNotification, 0, len(uuids))
for _, uuid := range uuids {
if uuid == "" {
return nil, fmt.Errorf("client UUID cannot be empty")
}
existingNotification, ok := existingByClient[uuid]
if !ok || (!existingNotification.Daily && !existingNotification.Weekly && !existingNotification.Monthly) {
return nil, fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
}
notifications = append(notifications, models.TrafficReportNotification{
Client: uuid,
Enable: true,
})
}
return notifications, nil
}
// ListTrafficReportNotifications 获取所有流量定时报告配置(关联客户端信息)
func ListTrafficReportNotifications() ([]models.TrafficReportNotification, error) {
db := dbcore.GetDBInstance()
var notifications []models.TrafficReportNotification
err := db.Model(&models.TrafficReportNotification{}).Preload("ClientInfo").Find(&notifications).Error
return notifications, err
}
// EditTrafficReportNotifications 批量更新流量定时报告配置
func EditTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
if err := ValidateTrafficReportNotifications(notifications); err != nil {
return err
}
db := dbcore.GetDBInstance()
return db.Model(&models.TrafficReportNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable", "daily", "weekly", "monthly"}),
}).
Select("client", "enable", "daily", "weekly", "monthly").
Create(notifications).Error
}
// EnableTrafficReportNotifications 批量启用(仅更新 enable 字段)
func EnableTrafficReportNotifications(uuids []string) error {
if len(uuids) == 0 {
return fmt.Errorf("at least one client UUID is required")
}
db := dbcore.GetDBInstance()
var existing []models.TrafficReportNotification
if err := db.Where("client IN ?", uuids).Find(&existing).Error; err != nil {
return err
}
notifications, err := buildEnabledTrafficReportNotifications(uuids, existing)
if err != nil {
return err
}
return db.Model(&models.TrafficReportNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
}
// DisableTrafficReportNotifications 批量禁用
func DisableTrafficReportNotifications(uuids []string) error {
if len(uuids) == 0 {
return fmt.Errorf("at least one client UUID is required")
}
db := dbcore.GetDBInstance()
var notifications []models.TrafficReportNotification
for _, uuid := range uuids {
if uuid == "" {
return fmt.Errorf("client UUID cannot be empty")
}
notifications = append(notifications, models.TrafficReportNotification{
Client: uuid,
Enable: false,
})
}
return db.Model(&models.TrafficReportNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
}
// GetEnabledTrafficReportByType 获取启用了指定类型报告的客户端配置
func GetEnabledTrafficReportByType(daily, weekly, monthly bool) ([]models.TrafficReportNotification, error) {
db := dbcore.GetDBInstance()
var notifications []models.TrafficReportNotification
query := db.Model(&models.TrafficReportNotification{}).Where("enable = ?", true)
if daily {
query = query.Where("daily = ?", true)
} else if weekly {
query = query.Where("weekly = ?", true)
} else if monthly {
query = query.Where("monthly = ?", true)
}
err := query.Find(&notifications).Error
return notifications, err
}
@@ -0,0 +1,36 @@
package notification
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/komari-monitor/komari/database/models"
)
func TestValidateTrafficReportNotificationsRejectsEnabledWithoutCadence(t *testing.T) {
err := ValidateTrafficReportNotifications([]models.TrafficReportNotification{{
Client: "client-a",
Enable: true,
}})
assert.Error(t, err)
}
func TestBuildEnabledTrafficReportNotificationsRequiresExistingCadence(t *testing.T) {
_, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, nil)
assert.Error(t, err)
_, err = buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{Client: "client-a"}})
assert.Error(t, err)
notifications, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{
Client: "client-a",
Daily: true,
}})
require.NoError(t, err)
require.Len(t, notifications, 1)
assert.Equal(t, "client-a", notifications[0].Client)
assert.True(t, notifications[0].Enable)
}
+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
}
+248 -27
View File
@@ -18,14 +18,61 @@ func RecordOne(rec models.Record) error {
return db.Create(&rec).Error
}
func RecordGPU(rec models.GPURecord) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
}
func DeleteAll() error {
db := dbcore.GetDBInstance()
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
return err
}
if err := db.Exec("DELETE FROM gpu_records_long_term").Error; err != nil {
return err
}
if err := db.Exec("DELETE FROM gpu_records").Error; err != nil {
return err
}
return db.Exec("DELETE FROM records").Error
}
// GetGPURecordsByClientAndTime 获取GPU记录数据
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
db := dbcore.GetDBInstance()
var records []models.GPURecord
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recentRecords []models.GPURecord
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).
Order("time ASC, device_index ASC").Find(&recentRecords).Error
if err != nil {
log.Printf("Error fetching recent GPU records for client %s between %s and %s: %v", uuid, recentStart, end, err)
return nil, err
}
}
var longTermRecords []models.GPURecord
err := db.Table("gpu_records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).
Order("time ASC, device_index ASC").Find(&longTermRecords).Error
if err != nil {
log.Printf("Error fetching long-term GPU records for client %s between %s and %s: %v", uuid, start, end, err)
return recentRecords, nil
}
// 合并结果 - 不再需要类型转换
records = append(records, recentRecords...)
records = append(records, longTermRecords...)
return records, nil
}
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
db := dbcore.GetDBInstance()
err = db.Where("client = ?", uuid).Order("time DESC").Limit(1).Find(&Record).Error
@@ -35,6 +82,8 @@ func GetLatestRecord(uuid string) (Record []models.Record, err error) {
func DeleteRecordBefore(before time.Time) error {
db := dbcore.GetDBInstance()
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
db.Table("gpu_records_long_term").Where("time < ?", before).Delete(&models.GPURecord{})
db.Where("time < ?", before).Delete(&models.GPURecord{})
return db.Where("time < ?", before).Delete(&models.Record{}).Error
}
@@ -117,22 +166,33 @@ func CompactRecord() error {
return err
}
if flags.DatabaseType == "sqlite" {
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming database: %v", err)
}
err = migrateGPURecords(db)
if err != nil {
log.Printf("Error migrating GPU records: %v", err)
return err
}
if flags.IsSQLite() {
db.Exec("PRAGMA wal_checkpoint(PASSIVE);")
}
//log.Printf("Record compaction completed")
return nil
}
func migrateOldRecords(db *gorm.DB) error {
// 计算 4 小时前的时间
fourHoursAgo := time.Now().Add(-4 * time.Hour)
return migrateOldRecordsAt(db, time.Now())
}
func compactRecordCutoff(now time.Time) time.Time {
return now.Add(-4 * time.Hour).Truncate(15 * time.Minute)
}
func migrateOldRecordsAt(db *gorm.DB, now time.Time) error {
cutoff := compactRecordCutoff(now)
// 查询 records 表中超过 4 小时的记录
var records []models.Record
if err := db.Table("records").Where("time < ?", fourHoursAgo).Find(&records).Error; err != nil {
if err := db.Table("records").Where("time < ?", cutoff).Find(&records).Error; err != nil {
return err
}
@@ -142,23 +202,29 @@ func migrateOldRecords(db *gorm.DB) error {
// 按 Client 和 15 分钟时间段分组,并存储所有记录以计算分位数
type groupData struct {
Cpu []float32
Gpu []float32
Load []float32
Temp []float32
Ram []int64
RamTotal []int64
Swap []int64
SwapTotal []int64
Disk []int64
DiskTotal []int64
NetIn []int64
NetOut []int64
NetTotalUp []int64
NetTotalDown []int64
Process []int
Connections []int
ConnectionsUdp []int
Cpu []float32
Gpu []float32
Load []float32
Temp []float32
Ram []int64
RamTotal []int64
Swap []int64
SwapTotal []int64
Disk []int64
DiskTotal []int64
NetIn []int64
NetOut []int64
NetTotalUp []int64
NetTotalDown []int64
TrafficUp int64
TrafficDown int64
LatestTime time.Time
LatestTotalUp int64
LatestTotalDown int64
Process []int
Connections []int
ConnectionsUdp []int
Uptime []int64
}
groupedRecords := make(map[string]*groupData)
@@ -182,9 +248,17 @@ func migrateOldRecords(db *gorm.DB) error {
data.NetOut = append(data.NetOut, record.NetOut)
data.NetTotalUp = append(data.NetTotalUp, record.NetTotalUp)
data.NetTotalDown = append(data.NetTotalDown, record.NetTotalDown)
data.TrafficUp += record.TrafficUp
data.TrafficDown += record.TrafficDown
if data.LatestTime.IsZero() || record.Time.ToTime().After(data.LatestTime) {
data.LatestTime = record.Time.ToTime()
data.LatestTotalUp = record.NetTotalUp
data.LatestTotalDown = record.NetTotalDown
}
data.Process = append(data.Process, record.Process)
data.Connections = append(data.Connections, record.Connections)
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
//data.Uptime = append(data.Uptime, record.Uptime)
}
getPercentile := func(values []float64, percentile float64) float64 {
@@ -274,11 +348,14 @@ func migrateOldRecords(db *gorm.DB) error {
DiskTotal: getIntPercentile(data.DiskTotal, 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),
NetTotalUp: data.LatestTotalUp,
NetTotalDown: data.LatestTotalDown,
TrafficUp: data.TrafficUp,
TrafficDown: data.TrafficDown,
Process: getInt32Percentile(data.Process, high_percentile),
Connections: getInt32Percentile(data.Connections, high_percentile),
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
//Uptime: getIntPercentile(data.Uptime, high_percentile),
}
// 如果记录已存在则更新,否则创建新记录
@@ -294,7 +371,151 @@ func migrateOldRecords(db *gorm.DB) error {
}
// 删除 records 表中的旧数据
if err := tx.Table("records").Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
if err := tx.Table("records").Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
return err
}
return nil
})
}
// migrateGPURecords 压缩GPU记录数据
func migrateGPURecords(db *gorm.DB) error {
cutoff := compactRecordCutoff(time.Now())
// 查询超过4小时的GPU记录
var gpuRecords []models.GPURecord
if err := db.Where("time < ?", cutoff).Find(&gpuRecords).Error; err != nil {
return err
}
if len(gpuRecords) == 0 {
return nil
}
// 按Client + DeviceIndex + 15分钟时间窗口分组
type gpuGroupKey struct {
Client string
DeviceIndex int
TimeSlot time.Time
DeviceName string
}
type gpuGroupData struct {
MemTotal []int64
MemUsed []int64
Utilization []float32
Temperature []int
}
groupedGPUs := make(map[gpuGroupKey]*gpuGroupData)
for _, record := range gpuRecords {
key := gpuGroupKey{
Client: record.Client,
DeviceIndex: record.DeviceIndex,
TimeSlot: record.Time.ToTime().Truncate(15 * time.Minute),
DeviceName: record.DeviceName,
}
if _, ok := groupedGPUs[key]; !ok {
groupedGPUs[key] = &gpuGroupData{}
}
data := groupedGPUs[key]
data.MemTotal = append(data.MemTotal, record.MemTotal)
data.MemUsed = append(data.MemUsed, record.MemUsed)
data.Utilization = append(data.Utilization, record.Utilization)
data.Temperature = append(data.Temperature, record.Temperature)
}
// 百分位数计算函数 (复用传统Record压缩逻辑)
getPercentile := func(values []float64, percentile float64) float64 {
if len(values) == 0 {
return 0
}
sortedValues := make([]float64, len(values))
copy(sortedValues, values)
sort.Float64s(sortedValues)
index := float64(len(sortedValues)-1) * percentile
lowerIndex := int(index)
if lowerIndex >= len(sortedValues)-1 {
return sortedValues[len(sortedValues)-1]
}
frac := index - float64(lowerIndex)
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
}
getIntPercentile := func(values []int64, percentile float64) int64 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int64(getPercentile(floats, percentile))
}
// 温度数据转换辅助函数
convertIntToInt64 := func(values []int) []int64 {
result := make([]int64, len(values))
for i, v := range values {
result[i] = int64(v)
}
return result
}
getFloat32Percentile := func(values []float32, percentile float64) float32 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return float32(getPercentile(floats, percentile))
}
// 保持与传统Record压缩的一致性
high_percentile := 0.7
return db.Transaction(func(tx *gorm.DB) error {
for key, data := range groupedGPUs {
// 检查是否已存在记录
var existingCount int64
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
key.Client, key.DeviceIndex, key.TimeSlot).Count(&existingCount).Error; err != nil {
return err
}
compressedGPU := models.GPURecord{
Client: key.Client,
DeviceIndex: key.DeviceIndex,
Time: models.FromTime(key.TimeSlot),
DeviceName: key.DeviceName,
MemTotal: getIntPercentile(data.MemTotal, high_percentile),
MemUsed: getIntPercentile(data.MemUsed, high_percentile),
Utilization: getFloat32Percentile(data.Utilization, high_percentile),
Temperature: int(getIntPercentile(convertIntToInt64(data.Temperature), high_percentile)),
}
if existingCount > 0 {
// 更新已存在记录
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
key.Client, key.DeviceIndex, key.TimeSlot).Updates(&compressedGPU).Error; err != nil {
return err
}
} else {
// 创建新记录
if err := tx.Table("gpu_records_long_term").Create(&compressedGPU).Error; err != nil {
return err
}
}
}
// 删除已压缩的原始GPU数据
if err := tx.Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
return err
}
+112 -8
View File
@@ -25,8 +25,9 @@ var _ = func() bool {
// then running migrateOldRecords and verifying the aggregation and cleanup.
func TestCompactRecord(t *testing.T) {
const totalMinutes = 12*60 + 30
now := time.Now()
threshold := now.Add(-4 * time.Hour)
now := time.Now().UTC().Truncate(time.Minute)
threshold := compactRecordCutoff(now)
overlapCutoff := threshold.Add(-1 * time.Hour)
// 使用 sqlite 内存数据库并迁移表结构
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -35,7 +36,7 @@ func TestCompactRecord(t *testing.T) {
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
expectedGroups := make(map[time.Time]struct{})
expectedRemain := 0
expectedRawRemain := 0
// 插入数据
for i := 0; i < totalMinutes; i++ {
@@ -45,10 +46,11 @@ func TestCompactRecord(t *testing.T) {
assert.NoError(t, err)
if recTime.Before(threshold) {
slot := recTime.Truncate(time.Hour)
slot := recTime.Truncate(15 * time.Minute)
expectedGroups[slot] = struct{}{}
} else {
expectedRemain++
}
if !recTime.Before(overlapCutoff) {
expectedRawRemain++
}
}
@@ -75,7 +77,7 @@ func TestCompactRecord(t *testing.T) {
}
// 运行压缩(迁移)逻辑
err = migrateOldRecords(db)
err = migrateOldRecordsAt(db, now)
assert.NoError(t, err)
// 验证 long-term 表中的聚合记录数
@@ -86,7 +88,7 @@ func TestCompactRecord(t *testing.T) {
// 验证原始表中剩余记录数
var remainCount int64
assert.NoError(t, db.Table("records").Count(&remainCount).Error)
assert.Equal(t, int64(expectedRemain), remainCount+1)
assert.Equal(t, int64(expectedRawRemain), remainCount)
// 导出压缩后的数据到 CSV
var compRecs []models.Record
@@ -128,3 +130,105 @@ func TestCompactRecord(t *testing.T) {
})
}
}
func TestCompactRecordPreservesExactTrafficDelta(t *testing.T) {
currentTime := time.Now().UTC().Truncate(time.Minute)
now := currentTime.Truncate(15 * time.Minute).Add(-5*time.Hour + time.Minute)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
records := []models.Record{
{
Client: uuid,
Time: models.FromTime(now),
NetTotalUp: 100,
NetTotalDown: 200,
TrafficUp: 0,
TrafficDown: 0,
},
{
Client: uuid,
Time: models.FromTime(now.Add(5 * time.Minute)),
NetTotalUp: 150,
NetTotalDown: 260,
TrafficUp: 50,
TrafficDown: 60,
},
{
Client: uuid,
Time: models.FromTime(now.Add(10 * time.Minute)),
NetTotalUp: 10,
NetTotalDown: 30,
TrafficUp: 10,
TrafficDown: 30,
},
}
for _, rec := range records {
assert.NoError(t, db.Create(&rec).Error)
}
assert.NoError(t, migrateOldRecordsAt(db, currentTime))
var compacted []models.Record
assert.NoError(t, db.Table("records_long_term").Find(&compacted).Error)
assert.Len(t, compacted, 1)
assert.Equal(t, int64(60), compacted[0].TrafficUp)
assert.Equal(t, int64(90), compacted[0].TrafficDown)
assert.Equal(t, int64(10), compacted[0].NetTotalUp)
assert.Equal(t, int64(30), compacted[0].NetTotalDown)
assert.True(t, compacted[0].Time.ToTime().Equal(records[2].Time.ToTime().Truncate(15*time.Minute)))
}
func TestCompactRecordRetainsOneHourOverlapWindow(t *testing.T) {
now := time.Date(2026, 6, 7, 12, 7, 0, 0, time.UTC)
cutoff := compactRecordCutoff(now)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
records := []models.Record{
{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Hour - time.Minute)), TrafficUp: 5},
{Client: uuid, Time: models.FromTime(cutoff.Add(-30 * time.Minute)), TrafficUp: 7},
{Client: uuid, Time: models.FromTime(now.Add(-3 * time.Hour)), TrafficUp: 11},
}
for _, rec := range records {
assert.NoError(t, db.Create(&rec).Error)
}
assert.NoError(t, migrateOldRecordsAt(db, now))
var remainTimes []models.Record
assert.NoError(t, db.Table("records").Order("time ASC").Find(&remainTimes).Error)
assert.Len(t, remainTimes, 2)
assert.True(t, remainTimes[0].Time.ToTime().Equal(records[1].Time.ToTime()))
assert.True(t, remainTimes[1].Time.ToTime().Equal(records[2].Time.ToTime()))
}
func TestCompactRecordOnlyMigratesCompleteFifteenMinuteBuckets(t *testing.T) {
now := time.Date(2026, 6, 7, 12, 7, 0, 0, time.UTC)
cutoff := compactRecordCutoff(now)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
compactable := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Minute)), TrafficUp: 5}
partialSlot := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(time.Minute)), TrafficUp: 7}
assert.NoError(t, db.Create(&compactable).Error)
assert.NoError(t, db.Create(&partialSlot).Error)
assert.NoError(t, migrateOldRecordsAt(db, now))
var compacted []models.Record
assert.NoError(t, db.Table("records_long_term").Order("time ASC").Find(&compacted).Error)
assert.Len(t, compacted, 1)
assert.True(t, compacted[0].Time.ToTime().Equal(compactable.Time.ToTime().Truncate(15*time.Minute)))
var rawCount int64
assert.NoError(t, db.Table("records").Where("time = ?", partialSlot.Time).Count(&rawCount).Error)
assert.Equal(t, int64(1), rawCount)
}
+126 -20
View File
@@ -9,16 +9,35 @@ import (
"gorm.io/gorm"
)
func AddPingTask(clients []string, name string, target, task_type string, interval int) (uint, error) {
// AddPingTask 创建延迟监测任务。defaultOn 表示新加入的服务器是否自动开启此监测。
func AddPingTask(clients []string, defaultOn bool, name string, target, task_type string, interval int) (uint, error) {
db := dbcore.GetDBInstance()
normalizedClients := normalizePingClients(models.StringArray(clients))
task := models.PingTask{
Clients: clients,
Name: name,
Type: task_type,
Target: target,
Interval: interval,
Clients: normalizedClients,
DefaultOn: defaultOn,
Name: name,
Type: task_type,
Target: target,
Interval: interval,
}
if err := db.Create(&task).Error; err != nil {
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&task).Error; err != nil {
return err
}
// Append by id to avoid races between concurrent create requests.
result := tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
if err != nil {
return 0, err
}
ReloadPingSchedule()
@@ -35,10 +54,21 @@ func DeletePingTask(id []uint) error {
return result.Error
}
// EditPingTask 批量更新延迟监测任务配置。
func EditPingTask(tasks []*models.PingTask) error {
db := dbcore.GetDBInstance()
for _, task := range tasks {
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(task)
task.Clients = normalizePingClients(task.Clients)
// 使用 map 显式更新,避免 GORM struct Updates 跳过 false/0/空切片等零值。
updates := map[string]interface{}{
"name": task.Name,
"clients": task.Clients,
"all_clients": task.DefaultOn,
"type": task.Type,
"target": task.Target,
"interval": task.Interval,
}
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(updates)
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
@@ -47,29 +77,59 @@ func EditPingTask(tasks []*models.PingTask) error {
return nil
}
// normalizePingClients 保持 clients 字段序列化为 JSON 数组,避免空值变成 null。
func normalizePingClients(clients models.StringArray) models.StringArray {
if clients == nil {
return models.StringArray{}
}
return clients
}
func GetAllPingTasks() ([]models.PingTask, error) {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Find(&tasks).Error; err != nil {
if err := db.Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
return nil, err
}
return tasks, nil
}
// GetPingTasksByClient 获取指定服务器需要执行的延迟监测任务。
func GetPingTasksByClient(uuid string) []models.PingTask {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Find(&tasks).Error; err != nil {
return nil
}
return tasks
}
func UpdatePingTaskOrder(order map[uint]int) error {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
for id, weight := range order {
result := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", weight)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
}
return nil
})
if err != nil {
return err
}
ReloadPingSchedule()
return nil
}
func SavePingRecord(record models.PingRecord) error {
db := dbcore.GetDBInstance()
return db.Create(&record).Error
}
func GetPingRecords(client string) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ?", client).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func DeletePingRecordsBefore(time time.Time) error {
db := dbcore.GetDBInstance()
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
@@ -101,10 +161,56 @@ func ReloadPingSchedule() error {
}
return utils.ReloadPingSchedule(pingTasks)
}
func GetPingRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.PingRecord, error) {
// AddDefaultOnClientUUID 在新客户端注册后,把该 UUID 追加到所有 default_on=true 的任务的 clients 中(去重)。
func AddDefaultOnClientUUID(uuid string) error {
if uuid == "" {
return nil
}
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
return err
}
if len(tasks) == 0 {
return nil
}
changed := false
for _, task := range tasks {
exists := false
for _, c := range task.Clients {
if c == uuid {
exists = true
break
}
}
if exists {
continue
}
next := append(models.StringArray{}, task.Clients...)
next = append(next, uuid)
if err := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("clients", next).Error; err != nil {
return err
}
changed = true
}
if changed {
return ReloadPingSchedule()
}
return nil
}
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time DESC").Find(&records).Error; err != nil {
dbQuery := db.Model(&models.PingRecord{})
if uuid != "" {
dbQuery = dbQuery.Where("client = ?", uuid)
}
if taskId >= 0 {
dbQuery = dbQuery.Where("task_id = ?", uint(taskId))
}
if err := dbQuery.Where("time >= ? AND time <= ?", start, end).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
+1 -1
View File
@@ -83,7 +83,7 @@ func DeleteTaskByTaskId(taskId string) error {
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
}
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp models.LocalTime) error {
return dbcore.GetDBInstance().
Model(&models.TaskResult{}).
Where("task_id = ? AND client = ?", taskId, clientId).
+145
View File
@@ -0,0 +1,145 @@
package database
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
)
func GetPublicInfo() (map[string]interface{}, error) {
cstPtr, err := config.GetManyAs[config.Settings]()
if err != nil {
return nil, err
}
cst := *cstPtr
all, allErr := config.GetAll()
hasKey := func(k string) bool {
if allErr != nil {
return false
}
_, ok := all[k]
return ok
}
// Apply defaults only when a key is missing.
if !hasKey("sitename") {
cst.Sitename = "Komari"
}
if !hasKey("description") {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
if !hasKey("theme") {
cst.Theme = "default"
}
if !hasKey("o_auth_provider") {
cst.OAuthProvider = "github"
}
if !hasKey("record_enabled") {
cst.RecordEnabled = true
}
if !hasKey("record_preserve_time") {
cst.RecordPreserveTime = 720
}
if !hasKey("ping_record_preserve_time") {
cst.PingRecordPreserveTime = 24
}
// Fallback defaults if we couldn't enumerate keys.
if allErr != nil {
if cst.Sitename == "" {
cst.Sitename = "Komari"
}
if cst.Description == "" {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
}
db := dbcore.GetDBInstance()
tc := models.ThemeConfiguration{}
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
if err != nil {
tc.Data = "{}"
}
tc_data := gin.H{}
err = json.Unmarshal([]byte(tc.Data), &tc_data)
if err != nil {
log.Printf("%v", err)
}
// Try to load theme declaration file and merge defaults for managed configuration
// Theme declarations live in ./data/theme/<short>/komari-theme.json
if cst.Theme != "" && cst.Theme != "default" {
themeConfigPath := filepath.Join("./data/theme", cst.Theme, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); err == nil {
b, err := os.ReadFile(themeConfigPath)
if err == nil {
var themeDecl struct {
Configuration struct {
Type string `json:"type"`
Data []models.ManagedThemeConfigurationItem `json:"data"`
} `json:"configuration"`
}
if err := json.Unmarshal(b, &themeDecl); err == nil {
if themeDecl.Configuration.Type == "managed" {
for _, item := range themeDecl.Configuration.Data {
if item.Key == "" {
continue
}
// missing
if _, exists := tc_data[item.Key]; !exists {
var def any = item.Default
// select
if item.Type == "select" {
if def == nil || def == "" {
if item.Options != "" {
opts := strings.Split(item.Options, ",")
if len(opts) > 0 {
def = strings.TrimSpace(opts[0])
}
}
}
}
// number->0, string->"", switch->false
if def == nil {
switch item.Type {
case "number":
def = 0
case "switch":
def = false
default:
def = ""
}
}
tc_data[item.Key] = def
}
}
}
}
}
}
}
return gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
"private_site": cst.PrivateSite,
"theme": cst.Theme,
"theme_settings": tc_data,
}, nil
}
+143
View File
@@ -0,0 +1,143 @@
# 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_)
- 1Panel アプリストア
1Panel アプリストアで利用可能です。「アプリストア」>「ユーティリティ」>「Komari」からインストールしてください。
### 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` プロジェクトのルートにある `/web/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/web/public/defaultTheme` にコピーします。
```bash
go build -o komari
```
3. 実行:
```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を歓迎します!
## 謝辞
### 破碎工坊云 (Crash Work)
[破碎工坊云 - 効率的で安定した、安全な高防御サーバーとCDNソリューションを提供する専門的なクラウドコンピューティングプラットフォーム](https://www.crash.work/)
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
### 🚀 SharonNetworks スポンサー
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
### オープンソースコミュニティ
PR を送ってくれた方、テーマを作成してくれた全ての開発者
—— そして:こんなに暇でいられる自分に感謝
## Star履歴
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+143
View File
@@ -0,0 +1,143 @@
# 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_)
- 1Panel 應用商店
已上架 1Panel 應用商店,應用商店-實用工具-Komari 即可安裝
### 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` 專案根目錄下的 `/web/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
3. 執行:
```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!
## 鳴謝
### 破碎工坊雲
[破碎工坊雲 - 專業雲計算服務平台,提供高效、穩定、安全的高防伺服器與CDN解決方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 贊助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
亞太資料中心提供頂級的中國優化網路接入 · 低延遲 & 高頻寬 & 提供 Tbps 級本地清洗高防服務,為您的業務保駕護航,為您的客戶提供極致體驗。加入社群 [Telegram 群組](https://t.me/SharonNetwork) 可參與公益募捐或群內抽獎免費使用。
### 開源社群
提交 PR、製作主題的各位開發者
—— 以及:感謝我自己能這麼閒
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+143
View File
@@ -0,0 +1,143 @@
# 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/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [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_)
- 1Panel 应用商店
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
### 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` 项目根目录下的 `/web/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
3. 运行:
```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!
## 鸣谢
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 赞助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时 & 高带宽 & 提供 Tbps 级本地清洗高防服务,为您的业务保驾护航,为您的客户提供极致体验。加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用。
### 开源社区
提交 PR、制作主题的各位开发者
—— 以及:感谢我自己能这么闲
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+20 -12
View File
@@ -1,8 +1,13 @@
module github.com/komari-monitor/komari
go 1.23.2
go 1.24.0
toolchain go1.24.11
require (
github.com/coreos/go-oidc/v3 v3.15.0
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -11,41 +16,43 @@ require (
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
gorm.io/driver/mysql v1.5.7
golang.org/x/image v0.35.0
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
)
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
// golang.org/x/crypto Vulnerable to Denial of Service (DoS) via Slow or Incomplete Key Exchange #3
require golang.org/x/crypto v0.37.0 // indirect
require golang.org/x/crypto v0.39.0 // indirect
// HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net #2
// golang.org/x/net vulnerable to Cross-site Scripting #4
require golang.org/x/net v0.39.0 // indirect
require golang.org/x/net v0.41.0 // indirect
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.27 // indirect
@@ -58,9 +65,10 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+56 -19
View File
@@ -1,5 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
@@ -10,17 +10,31 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -29,14 +43,17 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -83,7 +100,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -107,17 +123,39 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -125,14 +163,13 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+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
+9
View File
@@ -2,12 +2,21 @@ package main
import (
"log"
"log/slog"
"github.com/komari-monitor/komari/cmd"
"github.com/komari-monitor/komari/utils"
logutil "github.com/komari-monitor/komari/utils/log"
)
func main() {
if utils.VersionHash == "unknown" {
logutil.SetupGlobalLogger(slog.LevelDebug)
} else {
logutil.SetupGlobalLogger(slog.LevelInfo)
}
log.Printf("Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
cmd.Execute()
}
+635
View File
@@ -0,0 +1,635 @@
package config
import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"strings"
"sync"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ConfigItem struct {
Key string `gorm:"primaryKey;column:key;type:text"`
Value string `gorm:"column:value;type:text"` // 存 JSON 字符串
}
func (ConfigItem) TableName() string {
return "configs"
}
var (
db *gorm.DB
SetDb = func(gdb *gorm.DB) {
db = gdb
if err := db.AutoMigrate(&ConfigItem{}); err != nil {
panic("failed to migrate config item table: " + err.Error())
}
}
)
// Get 获取原始值 (反序列化为 interface{})
func Get(key string, defaul ...any) (any, error) {
var item ConfigItem
err := db.First(&item, "key = ?", key).Error
if err != nil {
if len(defaul) > 0 {
v := defaul[0]
err = Set(key, v)
return v, err
}
return nil, err
}
var val any
if err := json.Unmarshal([]byte(item.Value), &val); err != nil {
return nil, err
}
return val, nil
}
// GetAs 获取并转换为指定类型 (泛型),支持数值类型自动转换
func GetAs[T any](key string, defaul ...any) (T, error) {
var t T
var item ConfigItem
err := db.First(&item, "key = ?", key).Error
if err != nil {
if len(defaul) > 0 {
// 尝试直接类型断言
if v, ok := defaul[0].(T); ok {
err = Set(key, v)
return v, err
}
// 尝试类型转换
val := reflect.ValueOf(&t).Elem()
if err := convertAndSet(defaul[0], val); err != nil {
return t, fmt.Errorf("default value type mismatch: expected %T, got %T", t, defaul[0])
}
err = Set(key, t)
return t, err
}
return t, err
}
// 先尝试直接反序列化
if err = json.Unmarshal([]byte(item.Value), &t); err != nil {
// 尝试通用解析后转换
var generic any
if err := json.Unmarshal([]byte(item.Value), &generic); err != nil {
return t, err
}
val := reflect.ValueOf(&t).Elem()
if err := convertAndSet(generic, val); err != nil {
return t, err
}
}
return t, nil
}
// GetMany 获取多个配置项,keys 为 map[key]defaultValue
// 如果 defaultValue 为 nil,则数据库不存在时不写入
// 如果 defaultValue 不为 nil,则数据库不存在时写入默认值
func GetMany(keys map[string]any) (map[string]any, error) {
var items []ConfigItem
result := make(map[string]any)
keyList := make([]string, 0, len(keys))
for k := range keys {
keyList = append(keyList, k)
}
if len(keyList) == 0 {
return result, nil
}
if err := db.Where("key IN ?", keyList).Find(&items).Error; err != nil {
return nil, err
}
foundKeys := make(map[string]bool)
for _, item := range items {
var parsed any
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
result[item.Key] = parsed
foundKeys[item.Key] = true
}
}
// 收集需要写入数据库的默认值
var toInsert []ConfigItem
for k, def := range keys {
if _, found := foundKeys[k]; !found {
if def != nil {
result[k] = def
// 序列化后加入待写入列表
jsonBytes, err := json.Marshal(def)
if err != nil {
slog.Warn("marshal default value failed", "key", k, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
Key: k,
Value: string(jsonBytes),
})
}
}
}
// 批量写入默认值到数据库
if len(toInsert) > 0 {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
}
}
return result, nil
}
// GetManyAs 将多个配置项映射到一个结构体中,json tag 作为 Key
// 支持 default tag 作为默认值,如果数据库中不存在且有 default tag 则写入数据库
// 没有 default tag 的字段使用零值,不写入数据库
func GetManyAs[T any]() (*T, error) {
var t T
val := reflect.ValueOf(&t).Elem()
typ := val.Type()
type fieldInfo struct {
index int
key string
hasDefault bool
defaultVal string
}
fields := make([]fieldInfo, 0)
keys := make([]string, 0)
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// 解析 json tag,处理 "key,omitempty" 格式
key := strings.Split(jsonTag, ",")[0]
if key == "" || key == "-" {
continue
}
defaultTag := field.Tag.Get("default")
hasDefault := defaultTag != "" || field.Tag.Get("default") == ""
// 检查是否显式定义了 default tag (即使值为空)
_, hasDefault = field.Tag.Lookup("default")
fields = append(fields, fieldInfo{
index: i,
key: key,
hasDefault: hasDefault,
defaultVal: defaultTag,
})
keys = append(keys, key)
}
if len(keys) == 0 {
return &t, nil
}
var items []ConfigItem
if err := db.Where("key IN ?", keys).Find(&items).Error; err != nil {
return nil, err
}
// 建立数据库中存在的 key 映射
foundItems := make(map[string]string) // key -> value
for _, item := range items {
foundItems[item.Key] = item.Value
}
// 需要写入数据库的新配置项
var toInsert []ConfigItem
for _, fi := range fields {
fieldVal := val.Field(fi.index)
if !fieldVal.CanSet() {
continue
}
if dbValue, found := foundItems[fi.key]; found {
// 数据库中存在,使用数据库值
if err := unmarshalToField(dbValue, fieldVal); err != nil {
slog.Warn("unmarshal config failed", "key", fi.key, "error", err)
}
} else if fi.hasDefault {
// 数据库中不存在,但有 default tag,解析默认值并写入数据库
if err := parseDefaultToField(fi.defaultVal, fieldVal); err != nil {
slog.Warn("parse default value failed", "key", fi.key, "error", err)
continue
}
// 序列化后写入数据库
jsonBytes, err := json.Marshal(fieldVal.Interface())
if err != nil {
slog.Warn("marshal default value failed", "key", fi.key, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
Key: fi.key,
Value: string(jsonBytes),
})
}
// 没有 default tag 且数据库中不存在,保持零值,不写入数据库
}
// 批量写入默认值到数据库
if len(toInsert) > 0 {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
}
}
return &t, nil
}
// unmarshalToField 将 JSON 字符串反序列化到字段,支持数值类型转换
func unmarshalToField(jsonStr string, fieldVal reflect.Value) error {
target := reflect.New(fieldVal.Type()).Interface()
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
// 尝试通用解析后转换
var generic any
if err := json.Unmarshal([]byte(jsonStr), &generic); err != nil {
return err
}
return convertAndSet(generic, fieldVal)
}
fieldVal.Set(reflect.ValueOf(target).Elem())
return nil
}
// parseDefaultToField 解析 default tag 值到字段
func parseDefaultToField(defaultVal string, fieldVal reflect.Value) error {
kind := fieldVal.Kind()
switch kind {
case reflect.String:
fieldVal.SetString(defaultVal)
case reflect.Bool:
fieldVal.SetBool(defaultVal == "true" || defaultVal == "1")
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
var v int64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
// 尝试解析浮点数后转换
var f float64
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
return err
}
v = int64(f)
}
}
fieldVal.SetInt(v)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
var v uint64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
var f float64
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
return err
}
v = uint64(f)
}
}
fieldVal.SetUint(v)
case reflect.Float32, reflect.Float64:
var v float64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%f", &v); err != nil {
return err
}
}
fieldVal.SetFloat(v)
default:
// 对于复杂类型,尝试 JSON 解析
if defaultVal == "" {
return nil // 保持零值
}
target := reflect.New(fieldVal.Type()).Interface()
if err := json.Unmarshal([]byte(defaultVal), target); err != nil {
return err
}
fieldVal.Set(reflect.ValueOf(target).Elem())
}
return nil
}
// convertAndSet 通用类型转换并设置字段值
func convertAndSet(val any, fieldVal reflect.Value) error {
if val == nil {
return nil
}
targetType := fieldVal.Type()
v := reflect.ValueOf(val)
// 直接类型匹配
if v.Type().AssignableTo(targetType) {
fieldVal.Set(v)
return nil
}
// 类型可转换
if v.Type().ConvertibleTo(targetType) {
fieldVal.Set(v.Convert(targetType))
return nil
}
// 数值类型特殊处理 (JSON 数字默认解析为 float64)
if f, ok := val.(float64); ok {
switch fieldVal.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
fieldVal.SetInt(int64(f))
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
fieldVal.SetUint(uint64(f))
return nil
case reflect.Float32, reflect.Float64:
fieldVal.SetFloat(f)
return nil
}
}
// JSON 回环转换
b, err := json.Marshal(val)
if err != nil {
return err
}
target := reflect.New(targetType).Interface()
if err := json.Unmarshal(b, target); err != nil {
return err
}
fieldVal.Set(reflect.ValueOf(target).Elem())
return nil
}
func GetAll() (map[string]any, error) {
var items []ConfigItem
result := make(map[string]any)
if err := db.Find(&items).Error; err != nil {
return nil, err
}
for _, item := range items {
var parsed any
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
result[item.Key] = parsed
}
}
return result, nil
}
// Set 设置单个配置
func Set(key string, value any) error {
oldVal := map[string]any{}
{
var oldItem ConfigItem
if err := db.First(&oldItem, "key = ?", key).Error; err == nil {
var parsed any
if err := json.Unmarshal([]byte(oldItem.Value), &parsed); err == nil {
oldVal[key] = parsed
}
}
}
bytes, err := json.Marshal(value)
if err != nil {
return err
}
item := ConfigItem{
Key: key,
Value: string(bytes),
}
err = db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&item).Error
if err != nil {
return err
}
newVal := map[string]any{key: value}
publishEvent(oldVal, newVal)
return nil
}
// SetMany 将结构体保存为多个配置项
func SetManyAs[T any](config T) error {
val := reflect.ValueOf(config)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
typ := val.Type()
var items []ConfigItem
for i := 0; i < val.NumField(); i++ {
fieldType := typ.Field(i)
tag := fieldType.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
fieldValue := val.Field(i).Interface()
bytes, err := json.Marshal(fieldValue)
if err != nil {
return fmt.Errorf("marshal field %s failed: %w", fieldType.Name, err)
}
items = append(items, ConfigItem{
Key: tag,
Value: string(bytes),
})
}
if len(items) == 0 {
return nil
}
keys := make([]string, 0, len(items))
newVal := make(map[string]any, len(items))
for _, it := range items {
keys = append(keys, it.Key)
var parsed any
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
newVal[it.Key] = parsed
}
}
oldVal := map[string]any{}
if len(keys) > 0 {
var oldItems []ConfigItem
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
for _, oi := range oldItems {
var parsed any
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
oldVal[oi.Key] = parsed
}
}
}
}
err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&items).Error
if err != nil {
return err
}
publishEvent(oldVal, newVal)
return nil
}
func SetMany(cst map[string]any) error {
var items []ConfigItem
for k, v := range cst {
bytes, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal key %s failed: %w", k, err)
}
items = append(items, ConfigItem{
Key: k,
Value: string(bytes),
})
}
if len(items) == 0 {
return nil
}
keys := make([]string, 0, len(items))
newVal := make(map[string]any, len(items))
for _, it := range items {
keys = append(keys, it.Key)
var parsed any
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
newVal[it.Key] = parsed
}
}
oldVal := map[string]any{}
if len(keys) > 0 {
var oldItems []ConfigItem
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
for _, oi := range oldItems {
var parsed any
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
oldVal[oi.Key] = parsed
}
}
}
}
err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&items).Error
if err != nil {
return err
}
publishEvent(oldVal, newVal)
return nil
}
type ConfigEvent struct {
Old map[string]any
New map[string]any
}
func (e ConfigEvent) IsChanged(key string) bool {
oldVal, oldOk := e.Old[key]
newVal, newOk := e.New[key]
if !oldOk && !newOk {
return false
}
if oldOk != newOk {
return true
}
return !reflect.DeepEqual(oldVal, newVal)
}
func IsChangedT[T any](e ConfigEvent, key string) (bool, T) {
changed := e.IsChanged(key)
var zero T
val, ok := e.New[key]
if !ok {
val, ok = e.Old[key]
if !ok {
return changed, zero
}
}
if val == nil {
return changed, zero
}
// Fast path: direct assertion.
if cast, ok := val.(T); ok {
return changed, cast
}
// Try reflection-based conversion (covers numeric conversions, etc.).
targetType := reflect.TypeOf((*T)(nil)).Elem()
v := reflect.ValueOf(val)
if v.IsValid() {
if v.Type().AssignableTo(targetType) {
return changed, v.Interface().(T)
}
if v.Type().ConvertibleTo(targetType) {
converted := v.Convert(targetType)
return changed, converted.Interface().(T)
}
}
// Fallback: JSON roundtrip for map/struct and other loosely typed values.
if b, err := json.Marshal(val); err == nil {
var out T
if err := json.Unmarshal(b, &out); err == nil {
return changed, out
}
}
return changed, zero
}
// ConfigSubscriber handles config events
type ConfigSubscriber func(event ConfigEvent)
var (
subscribersMu sync.RWMutex
subscribers []ConfigSubscriber
)
// Subscribe registers a subscriber for all config events.
func Subscribe(subscriber ConfigSubscriber) {
subscribersMu.Lock()
defer subscribersMu.Unlock()
subscribers = append(subscribers, subscriber)
}
// publishEvent notifies all subscribers of a config change.
func publishEvent(oldVal, newVal map[string]any) {
subscribersMu.RLock()
defer subscribersMu.RUnlock()
for _, sub := range subscribers {
event := ConfigEvent{Old: oldVal, New: newVal}
go sub(event)
}
}
+87
View File
@@ -0,0 +1,87 @@
package config
import "time"
type Settings struct {
ID uint `json:"id,omitempty"` // 1
Sitename string `json:"sitename" default:"Komari"` // 站点名称,默认 "Komari"
Description string `json:"description" default:"A simple server monitor tool."` // 站点描述
CorsOriginCheckEnabled bool `json:"cors_origin_check_enabled" default:"true"` // 是否启用 API CORS 跨域请求校验,默认 true
CorsAllowedOrigins string `json:"cors_allowed_origins" default:""` // API 跨域允许列表
WsOriginCheckEnabled bool `json:"ws_origin_check_enabled" default:"true"` // 是否校验 WebSocket Origin
WsAllowedOrigins string `json:"ws_allowed_origins" default:""` // WebSocket Origin 允许列表
Theme string `json:"theme" default:"default"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" default:"false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" default:""` // API 密钥,默认空字符串
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" default:"false"`
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" default:"true"`
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" default:"false"`
NezhaCompatListen string `json:"nezha_compat_listen" default:""` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
OAuthProvider string `json:"o_auth_provider" default:"github"`
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
CloudflareTunnelToken string `json:"cloudflare_tunnel_token" default:""`
// 自定义美化
CustomHead string `json:"custom_head" default:""`
CustomBody string `json:"custom_body" default:""`
// 通知
NotificationEnabled bool `json:"notification_enabled" default:"true"` // 通知总开关
NotificationMethod string `json:"notification_method" default:"none"`
NotificationTemplate string `json:"notification_template" default:"{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" default:"true"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" default:"7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" default:"true"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" default:"80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" default:"true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" default:"720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" default:"24"` // Ping 记录保留时间,单位小时,默认1天
UpdatedAt time.Time
}
const (
SitenameKey = "sitename"
DescriptionKey = "description"
CorsOriginCheckEnabledKey = "cors_origin_check_enabled"
CorsAllowedOriginsKey = "cors_allowed_origins"
WsOriginCheckEnabledKey = "ws_origin_check_enabled"
WsAllowedOriginsKey = "ws_allowed_origins"
ThemeKey = "theme"
PrivateSiteKey = "private_site"
ApiKeyKey = "api_key"
AutoDiscoveryKeyKey = "auto_discovery_key"
ScriptDomainKey = "script_domain"
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
EulaAcceptedKey = "eula_accepted"
BaseScriptsURLKey = "base_scripts_url"
GeoIpEnabledKey = "geo_ip_enabled"
GeoIpProviderKey = "geo_ip_provider"
NezhaCompatEnabledKey = "nezha_compat_enabled"
NezhaCompatListenKey = "nezha_compat_listen"
OAuthEnabledKey = "o_auth_enabled"
OAuthProviderKey = "o_auth_provider"
DisablePasswordLoginKey = "disable_password_login"
CloudflareTunnelTokenKey = "cloudflare_tunnel_token"
CustomHeadKey = "custom_head"
CustomBodyKey = "custom_body"
NotificationEnabledKey = "notification_enabled"
NotificationMethodKey = "notification_method"
NotificationTemplateKey = "notification_template"
ExpireNotificationEnabledKey = "expire_notification_enabled"
ExpireNotificationLeadDaysKey = "expire_notification_lead_days"
LoginNotificationKey = "login_notification"
TrafficLimitPercentageKey = "traffic_limit_percentage"
RecordEnabledKey = "record_enabled"
RecordPreserveTimeKey = "record_preserve_time"
PingRecordPreserveTimeKey = "ping_record_preserve_time"
UpdatedAtKey = "updated_at"
XtermjsSettingsKey = "xtermjs_settings"
)
+345
View File
@@ -0,0 +1,345 @@
package corn
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
)
// Func 是 corn 调度器执行的任务函数。
// 调度器会为每次执行传入可取消的 context,便于任务在重载或关闭时尽快退出。
type Func func(ctx context.Context)
type job struct {
cancel context.CancelFunc
}
type schedule interface {
Next(time.Time) time.Time
}
type everySchedule struct {
interval time.Duration
}
func (s everySchedule) Next(t time.Time) time.Time {
return t.Add(s.interval)
}
type cronSchedule struct {
seconds map[int]struct{}
minutes map[int]struct{}
hours map[int]struct{}
dom map[int]struct{}
months map[int]struct{}
dow map[int]struct{}
}
func (s cronSchedule) Next(t time.Time) time.Time {
next := t.Truncate(time.Second).Add(time.Second)
limit := next.Add(366 * 24 * time.Hour)
for next.Before(limit) {
if s.match(next) {
return next
}
next = next.Add(time.Second)
}
return time.Time{}
}
func (s cronSchedule) match(t time.Time) bool {
_, okSecond := s.seconds[t.Second()]
_, okMinute := s.minutes[t.Minute()]
_, okHour := s.hours[t.Hour()]
_, okDay := s.dom[t.Day()]
_, okMonth := s.months[int(t.Month())]
_, okWeek := s.dow[int(t.Weekday())]
return okSecond && okMinute && okHour && okDay && okMonth && okWeek
}
type Manager struct {
mu sync.Mutex
jobs map[string]job
}
var defaultManager = NewManager()
func NewManager() *Manager {
return &Manager{jobs: make(map[string]job)}
}
// AddFunc 按 cron 表达式注册一个任务,fn 会在独立 goroutine 中执行。
// 支持 5 字段、6 字段 cron 表达式,以及 @every 1m 这类固定间隔表达式。
func AddFunc(name string, spec string, fn func()) error {
return AddContextFunc(name, spec, false, func(context.Context) { fn() })
}
// AddContextFunc 按 cron 表达式注册一个任务,支持传递带 context 的 func。
func AddContextFunc(name string, spec string, runImmediately bool, fn Func) error {
return defaultManager.AddContextFunc(name, spec, runImmediately, fn)
}
func Every(duration time.Duration) string {
return "@every " + duration.String()
}
func Remove(name string) {
defaultManager.Remove(name)
}
func RemovePrefix(prefix string) {
defaultManager.RemovePrefix(prefix)
}
func StopAll() {
defaultManager.StopAll()
}
func (m *Manager) AddFunc(name string, spec string, fn func()) error {
return m.AddContextFunc(name, spec, false, func(context.Context) { fn() })
}
func (m *Manager) AddContextFunc(name string, spec string, runImmediately bool, fn Func) error {
if name == "" {
return fmt.Errorf("corn job name is empty")
}
s, err := Parse(spec)
if err != nil {
return fmt.Errorf("corn job %q spec is invalid: %w", name, err)
}
if fn == nil {
return fmt.Errorf("corn job %q func is nil", name)
}
ctx, cancel := context.WithCancel(context.Background())
m.replace(name, cancel)
go m.run(ctx, name, s, runImmediately, fn)
return nil
}
func (m *Manager) Remove(name string) {
m.mu.Lock()
defer m.mu.Unlock()
if old, ok := m.jobs[name]; ok {
old.cancel()
delete(m.jobs, name)
}
}
func (m *Manager) RemovePrefix(prefix string) {
m.mu.Lock()
defer m.mu.Unlock()
for name, old := range m.jobs {
if strings.HasPrefix(name, prefix) {
old.cancel()
delete(m.jobs, name)
}
}
}
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for name, old := range m.jobs {
old.cancel()
delete(m.jobs, name)
}
}
func (m *Manager) replace(name string, cancel context.CancelFunc) {
m.mu.Lock()
defer m.mu.Unlock()
if old, ok := m.jobs[name]; ok {
old.cancel()
}
m.jobs[name] = job{cancel: cancel}
}
func (m *Manager) run(ctx context.Context, name string, s schedule, runImmediately bool, fn Func) {
if runImmediately {
go safeRun(ctx, name, fn)
}
nextTick := s.Next(time.Now())
if nextTick.IsZero() {
log.Printf("corn job %s has no next run time", name)
return
}
timer := time.NewTimer(time.Until(nextTick))
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
go safeRun(ctx, name, fn)
nextTick = s.Next(nextTick)
if nextTick.IsZero() {
return
}
resetTimer(timer, time.Until(nextTick))
}
}
}
func resetTimer(timer *time.Timer, duration time.Duration) {
if duration < 0 {
duration = 0
}
timer.Reset(duration)
}
func safeRun(ctx context.Context, name string, fn Func) {
defer func() {
if r := recover(); r != nil {
log.Printf("corn job %s panic: %v", name, r)
}
}()
select {
case <-ctx.Done():
return
default:
fn(ctx)
}
}
// Parse 解析 corn 表达式。
// 支持:
// - 5 字段:minute hour day-of-month month day-of-week
// - 6 字段:second minute hour day-of-month month day-of-week
// - @every 1m / @every 30s
//
// 字段支持 *、*/n、a-b、a-b/n、逗号列表和具体数字。
func Parse(spec string) (schedule, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, fmt.Errorf("empty spec")
}
if strings.HasPrefix(spec, "@every ") {
duration, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(spec, "@every ")))
if err != nil {
return nil, err
}
if duration <= 0 {
return nil, fmt.Errorf("@every duration must be positive")
}
return everySchedule{interval: duration}, nil
}
fields := strings.Fields(spec)
if len(fields) == 5 {
fields = append([]string{"0"}, fields...)
}
if len(fields) != 6 {
return nil, fmt.Errorf("expected 5 or 6 fields, got %d", len(fields))
}
seconds, err := parseField(fields[0], 0, 59)
if err != nil {
return nil, fmt.Errorf("second: %w", err)
}
minutes, err := parseField(fields[1], 0, 59)
if err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
hours, err := parseField(fields[2], 0, 23)
if err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
dom, err := parseField(fields[3], 1, 31)
if err != nil {
return nil, fmt.Errorf("day-of-month: %w", err)
}
months, err := parseField(fields[4], 1, 12)
if err != nil {
return nil, fmt.Errorf("month: %w", err)
}
dow, err := parseField(fields[5], 0, 7)
if err != nil {
return nil, fmt.Errorf("day-of-week: %w", err)
}
if _, ok := dow[7]; ok {
dow[0] = struct{}{}
delete(dow, 7)
}
return cronSchedule{seconds: seconds, minutes: minutes, hours: hours, dom: dom, months: months, dow: dow}, nil
}
func parseField(field string, min int, max int) (map[int]struct{}, error) {
values := make(map[int]struct{})
for _, part := range strings.Split(field, ",") {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("empty part")
}
base := part
step := 1
if strings.Contains(part, "/") {
parts := strings.Split(part, "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid step %q", part)
}
base = parts[0]
parsedStep, err := strconv.Atoi(parts[1])
if err != nil || parsedStep <= 0 {
return nil, fmt.Errorf("invalid step %q", parts[1])
}
step = parsedStep
}
start, end, err := parseRange(base, min, max)
if err != nil {
return nil, err
}
for i := start; i <= end; i += step {
values[i] = struct{}{}
}
}
return values, nil
}
func parseRange(base string, min int, max int) (int, int, error) {
if base == "*" || base == "" {
return min, max, nil
}
if strings.Contains(base, "-") {
parts := strings.Split(base, "-")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid range %q", base)
}
start, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("invalid range start %q", parts[0])
}
end, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("invalid range end %q", parts[1])
}
if start > end || start < min || end > max {
return 0, 0, fmt.Errorf("range %q out of bounds %d-%d", base, min, max)
}
return start, end, nil
}
value, err := strconv.Atoi(base)
if err != nil {
return 0, 0, fmt.Errorf("invalid value %q", base)
}
if value < min || value > max {
return 0, 0, fmt.Errorf("value %d out of bounds %d-%d", value, min, max)
}
return value, value, nil
}
+30
View File
@@ -0,0 +1,30 @@
package migrations
import "time"
// ClientInfo is the legacy table shape for migrating pre-Client model data.
type ClientInfo struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Name string `json:"name" gorm:"type:varchar(100);not null"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time
UpdatedAt time.Time
}
+471
View File
@@ -0,0 +1,471 @@
package migrations
import (
"encoding/json"
"fmt"
"log"
"reflect"
"strings"
"time"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/pkg/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type legacyModelConfig struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"`
PrivateSite bool `json:"private_site" gorm:"default:false"`
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"`
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"`
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"`
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"`
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"`
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"`
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"`
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"`
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"`
LoginNotification bool `json:"login_notification" gorm:"default:false"`
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"`
RecordEnabled bool `json:"record_enabled" gorm:"default:true"`
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"`
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"`
CreatedAt models.LocalTime
UpdatedAt models.LocalTime
}
func (legacyModelConfig) TableName() string {
return "configs"
}
type legacyConfig struct {
ID uint `json:"id,omitempty"`
Sitename string `json:"sitename"`
Description string `json:"description"`
Theme string `json:"theme"`
PrivateSite bool `json:"private_site"`
ApiKey string `json:"api_key"`
AutoDiscoveryKey string `json:"auto_discovery_key"`
ScriptDomain string `json:"script_domain"`
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest"`
EulaAccepted bool `json:"eula_accepted"`
BaseScriptsURLKey string `json:"base_scripts_url"`
GeoIpEnabled bool `json:"geo_ip_enabled"`
GeoIpProvider string `json:"geo_ip_provider"`
NezhaCompatEnabled bool `json:"nezha_compat_enabled"`
NezhaCompatListen string `json:"nezha_compat_listen"`
OAuthEnabled bool `json:"o_auth_enabled"`
OAuthProvider string `json:"o_auth_provider"`
DisablePasswordLogin bool `json:"disable_password_login"`
CloudflareTunnelToken string `json:"cloudflare_tunnel_token"`
CustomHead string `json:"custom_head"`
CustomBody string `json:"custom_body"`
NotificationEnabled bool `json:"notification_enabled"`
NotificationMethod string `json:"notification_method"`
NotificationTemplate string `json:"notification_template"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled"`
ExpireNotificationLeadDays int `json:"expire_notification_lead_days"`
LoginNotification bool `json:"login_notification"`
TrafficLimitPercentage float64 `json:"traffic_limit_percentage"`
RecordEnabled bool `json:"record_enabled"`
RecordPreserveTime int `json:"record_preserve_time"`
PingRecordPreserveTime int `json:"ping_record_preserve_time"`
UpdatedAt time.Time `json:"updated_at"`
}
func (legacyConfig) TableName() string {
return "configs"
}
type legacyPingTask struct {
Id uint `gorm:"column:id"`
Clients string `gorm:"column:clients"`
}
type Context struct {
DB *gorm.DB
}
// Run executes one-shot startup migrations before current runtime paths are used.
func Run(ctx Context) error {
db := ctx.DB
if db == nil {
return fmt.Errorf("migration database is nil")
}
legacyConfigTable := hasLegacyConfigTable(db)
if legacyConfigTable {
if err := migrateLegacyOidcConfig(db); err != nil {
return err
}
if err := migrateLegacyMessageSenderConfig(db); err != nil {
return err
}
}
if err := migrateLegacyClientInfo(db); err != nil {
return err
}
if err := migrateLegacyLoadNotification(db); err != nil {
return err
}
if err := migrateLegacyPingAllClientsExpansion(db); err != nil {
return err
}
if legacyConfigTable {
if err := migrateLegacyConfigToItems(db); err != nil {
return err
}
}
return nil
}
func hasLegacyConfigTable(db *gorm.DB) bool {
if !db.Migrator().HasTable("configs") {
return false
}
return hasTableColumn(db, "configs", "id") ||
hasTableColumn(db, "configs", "sitename")
}
func hasTableColumn(db *gorm.DB, tableName, columnName string) bool {
columns, err := db.Migrator().ColumnTypes(tableName)
if err != nil {
return false
}
for _, column := range columns {
if strings.EqualFold(column.Name(), columnName) {
return true
}
}
return false
}
func migrateLegacyLoadNotification(db *gorm.DB) error {
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
return db.Migrator().DropTable(&models.LoadNotification{})
}
return nil
}
func migrateLegacyOidcConfig(db *gorm.DB) error {
if db.Migrator().HasTable(&models.OidcProvider{}) {
return nil
}
log.Println("[>1.0.2] Merge OidcProvider table....")
var oldData struct {
OAuthClientID string `gorm:"column:o_auth_client_id"`
OAuthClientSecret string `gorm:"column:o_auth_client_secret"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&oldData).Error; err != nil {
return fmt.Errorf("get legacy OIDC config: %w", err)
}
if err := db.AutoMigrate(&models.OidcProvider{}); err != nil {
return err
}
addition, err := json.Marshal(map[string]string{
"client_id": oldData.OAuthClientID,
"client_secret": oldData.OAuthClientSecret,
})
if err != nil {
return fmt.Errorf("marshal legacy OIDC config: %w", err)
}
if err := db.Save(&models.OidcProvider{
Name: "github",
Addition: string(addition),
}).Error; err != nil {
return err
}
if err := db.AutoMigrate(&legacyModelConfig{}); err != nil {
return err
}
return db.Model(&legacyModelConfig{}).Where("id = 1").Update("o_auth_provider", "github").Error
}
func migrateLegacyMessageSenderConfig(db *gorm.DB) error {
if db.Migrator().HasTable(&models.MessageSenderProvider{}) {
return nil
}
log.Println("[>1.0.2] Migrate MessageSender configuration....")
var oldData struct {
TelegramBotToken string `gorm:"column:telegram_bot_token"`
TelegramChatID string `gorm:"column:telegram_chat_id"`
TelegramEndpoint string `gorm:"column:telegram_endpoint"`
EmailHost string `gorm:"column:email_host"`
EmailPort int `gorm:"column:email_port"`
EmailUsername string `gorm:"column:email_username"`
EmailPassword string `gorm:"column:email_password"`
EmailSender string `gorm:"column:email_sender"`
EmailReceiver string `gorm:"column:email_receiver"`
EmailUseSSL bool `gorm:"column:email_use_ssl"`
NotificationMethod string `gorm:"column:notification_method"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&oldData).Error; err != nil {
return fmt.Errorf("get legacy message sender config: %w", err)
}
if err := db.AutoMigrate(&models.MessageSenderProvider{}); err != nil {
return err
}
if oldData.NotificationMethod == "telegram" && oldData.TelegramBotToken != "" {
telegramConfig := map[string]interface{}{
"bot_token": oldData.TelegramBotToken,
"chat_id": oldData.TelegramChatID,
"endpoint": oldData.TelegramEndpoint,
}
if telegramConfig["endpoint"] == "" {
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
}
if err := saveLegacyMessageSenderConfig(db, "telegram", telegramConfig); err != nil {
return err
}
}
if oldData.NotificationMethod == "email" && oldData.EmailHost != "" {
emailConfig := map[string]interface{}{
"host": oldData.EmailHost,
"port": oldData.EmailPort,
"username": oldData.EmailUsername,
"password": oldData.EmailPassword,
"sender": oldData.EmailSender,
"receiver": oldData.EmailReceiver,
"use_ssl": oldData.EmailUseSSL,
}
if err := saveLegacyMessageSenderConfig(db, "email", emailConfig); err != nil {
return err
}
}
for _, column := range []string{
"telegram_bot_token",
"telegram_chat_id",
"telegram_endpoint",
"email_host",
"email_port",
"email_username",
"email_password",
"email_sender",
"email_receiver",
"email_use_ssl",
} {
if hasTableColumn(db, "configs", column) {
if err := db.Migrator().DropColumn(&legacyModelConfig{}, column); err != nil {
return err
}
}
}
return nil
}
func saveLegacyMessageSenderConfig(db *gorm.DB, name string, config map[string]interface{}) error {
configJSON, err := json.Marshal(config)
if err != nil {
return fmt.Errorf("marshal legacy %s message sender config: %w", name, err)
}
return db.Save(&models.MessageSenderProvider{
Name: name,
Addition: string(configJSON),
}).Error
}
func migrateLegacyConfigToItems(db *gorm.DB) error {
log.Println("[>1.1.4] Moving legacy config data...")
var oldData legacyConfig
if err := db.Order("id desc").First(&oldData).Error; err != nil {
if err := db.Migrator().DropTable("configs"); err != nil {
return err
}
return db.AutoMigrate(&appconfig.ConfigItem{})
}
newRows, err := legacyConfigRows(oldData)
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Migrator().DropTable("configs"); err != nil {
return err
}
if err := tx.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
return err
}
if len(newRows) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&newRows).Error
})
}
func legacyConfigRows(oldData legacyConfig) ([]appconfig.ConfigItem, error) {
val := reflect.ValueOf(oldData)
typ := reflect.TypeOf(oldData)
newRows := make([]appconfig.ConfigItem, 0, val.NumField())
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("json")
key := strings.Split(tag, ",")[0]
if key == "" || key == "-" || key == "id" {
continue
}
jsonBytes, err := json.Marshal(val.Field(i).Interface())
if err != nil {
return nil, fmt.Errorf("marshal legacy config %s: %w", key, err)
}
newRows = append(newRows, appconfig.ConfigItem{
Key: key,
Value: string(jsonBytes),
})
}
return newRows, nil
}
func migrateLegacyClientInfo(db *gorm.DB) error {
if !db.Migrator().HasTable("client_infos") {
return nil
}
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
if err := db.AutoMigrate(&models.Client{}); err != nil {
return err
}
var clientInfos []ClientInfo
if err := db.Find(&clientInfos).Error; err != nil {
return fmt.Errorf("read legacy ClientInfo table: %w", err)
}
for _, info := range clientInfos {
var client models.Client
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
continue
}
client.Name = info.Name
client.CpuName = info.CpuName
client.Virtualization = info.Virtualization
client.Arch = info.Arch
client.CpuCores = info.CpuCores
client.OS = info.OS
client.GpuName = info.GpuName
client.IPv4 = info.IPv4
client.IPv6 = info.IPv6
client.Region = info.Region
client.Remark = info.Remark
client.PublicRemark = info.PublicRemark
client.MemTotal = info.MemTotal
client.SwapTotal = info.SwapTotal
client.DiskTotal = info.DiskTotal
client.Version = info.Version
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
client.ExpiredAt = models.FromTime(info.ExpiredAt)
if err := db.Save(&client).Error; err != nil {
return fmt.Errorf("update Client record %s: %w", info.UUID, err)
}
}
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
return fmt.Errorf("backup legacy ClientInfo table: %w", err)
}
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
return nil
}
func migrateLegacyPingAllClientsExpansion(db *gorm.DB) error {
if !db.Migrator().HasTable(&models.PingTask{}) || !db.Migrator().HasTable(&models.Client{}) {
return nil
}
if !hasTableColumn(db, "ping_tasks", "all_clients") {
return nil
}
if !hasTableColumn(db, "ping_tasks", "clients") {
if err := db.Exec("ALTER TABLE ping_tasks ADD COLUMN clients text").Error; err != nil {
return fmt.Errorf("add clients column for legacy ping task expansion: %w", err)
}
}
if err := db.Table("ping_tasks").
Where("clients IS NULL OR clients = '' OR clients = '[]' OR clients = 'null'").
Update("clients", models.StringArray{}).Error; err != nil {
return fmt.Errorf("normalize legacy ping task clients: %w", err)
}
var pingTasks []legacyPingTask
if err := db.Table("ping_tasks").Select("id, clients").Where("all_clients = ?", true).Scan(&pingTasks).Error; err != nil {
return fmt.Errorf("find legacy all_clients ping tasks: %w", err)
}
if len(pingTasks) == 0 {
return nil
}
var clients []models.Client
if err := db.Select("uuid").Find(&clients).Error; err != nil {
return fmt.Errorf("find clients for legacy ping task expansion: %w", err)
}
if len(clients) == 0 {
return nil
}
allUUIDs := make(models.StringArray, 0, len(clients))
for _, client := range clients {
if client.UUID != "" {
allUUIDs = append(allUUIDs, client.UUID)
}
}
if len(allUUIDs) == 0 {
return nil
}
for _, task := range pingTasks {
if !isLegacyPingClientsEmpty(task.Clients) {
continue
}
if err := db.Table("ping_tasks").Where("id = ?", task.Id).Update("clients", allUUIDs).Error; err != nil {
return fmt.Errorf("expand legacy all_clients ping task %d: %w", task.Id, err)
}
}
return nil
}
func isLegacyPingClientsEmpty(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
return true
}
var clients []string
if err := json.Unmarshal([]byte(raw), &clients); err != nil {
return false
}
return len(clients) == 0
}
+259
View File
@@ -0,0 +1,259 @@
package migrations
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/pkg/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func openTestDB(t *testing.T, name string) *gorm.DB {
t.Helper()
dsn := "file:" + strings.ReplaceAll(name, " ", "_") + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite test db: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sqlite test db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
return db
}
func TestHasLegacyConfigTable(t *testing.T) {
t.Run("config item table", func(t *testing.T) {
db := openTestDB(t, "migrations_config_item")
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
t.Fatalf("migrate config item table: %v", err)
}
if hasLegacyConfigTable(db) {
t.Fatal("config item table was detected as legacy config table")
}
})
t.Run("legacy config table", func(t *testing.T) {
db := openTestDB(t, "migrations_legacy_config")
if err := db.AutoMigrate(&legacyModelConfig{}); err != nil {
t.Fatalf("migrate legacy config table: %v", err)
}
if !hasLegacyConfigTable(db) {
t.Fatal("legacy config table was not detected")
}
})
}
func TestRunSkipsLegacyConfigMigrationForCurrentConfigItemTable(t *testing.T) {
db := openTestDB(t, "migrations_config_item_run")
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
t.Fatalf("migrate config item table: %v", err)
}
if err := db.Create(&appconfig.ConfigItem{Key: "o_auth_provider", Value: `"github"`}).Error; err != nil {
t.Fatalf("seed config item: %v", err)
}
if err := Run(Context{DB: db}); err != nil {
t.Fatalf("run migrations: %v", err)
}
if db.Migrator().HasColumn(&legacyModelConfig{}, "id") {
t.Fatal("config item table was changed into the legacy config shape")
}
if db.Migrator().HasTable(&models.OidcProvider{}) {
t.Fatal("legacy OIDC migration ran against the config item table")
}
var item appconfig.ConfigItem
if err := db.First(&item, "key = ?", "o_auth_provider").Error; err != nil {
t.Fatalf("config item was not preserved: %v", err)
}
if item.Value != `"github"` {
t.Fatalf("unexpected config value: %s", item.Value)
}
}
func TestRunPreservesVersion120RuntimeShape(t *testing.T) {
db := openTestDB(t, "migrations_v120_runtime_shape")
if err := db.AutoMigrate(
&appconfig.ConfigItem{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.Client{},
&models.PingTask{},
); err != nil {
t.Fatalf("migrate 1.2.0 runtime shape: %v", err)
}
now := models.FromTime(time.Now())
if err := db.Create(&models.Client{UUID: "client-a", Token: "token-a", CreatedAt: now, UpdatedAt: now}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&models.PingTask{
Name: "already explicit",
Clients: models.StringArray{"client-a"},
DefaultOn: true,
Type: "icmp",
Target: "example.com",
Interval: 60,
}).Error; err != nil {
t.Fatalf("seed ping task: %v", err)
}
if err := db.Create(&appconfig.ConfigItem{Key: appconfig.OAuthProviderKey, Value: `"github"`}).Error; err != nil {
t.Fatalf("seed config item: %v", err)
}
if err := db.Create(&models.OidcProvider{Name: "github", Addition: `{"client_id":"old","client_secret":"secret"}`}).Error; err != nil {
t.Fatalf("seed oidc provider: %v", err)
}
if err := db.Create(&models.MessageSenderProvider{Name: "telegram", Addition: `{"bot_token":"old-token"}`}).Error; err != nil {
t.Fatalf("seed message sender provider: %v", err)
}
if err := Run(Context{DB: db}); err != nil {
t.Fatalf("run migrations: %v", err)
}
if db.Migrator().HasColumn(&legacyModelConfig{}, "sitename") {
t.Fatal("current config item table was treated as legacy wide config")
}
var configItem appconfig.ConfigItem
if err := db.First(&configItem, "key = ?", appconfig.OAuthProviderKey).Error; err != nil {
t.Fatalf("find config item: %v", err)
}
if configItem.Value != `"github"` {
t.Fatalf("unexpected config item value: %s", configItem.Value)
}
var oidc models.OidcProvider
if err := db.First(&oidc, "name = ?", "github").Error; err != nil {
t.Fatalf("find oidc provider: %v", err)
}
if oidc.Addition != `{"client_id":"old","client_secret":"secret"}` {
t.Fatalf("oidc provider was unexpectedly changed: %s", oidc.Addition)
}
var sender models.MessageSenderProvider
if err := db.First(&sender, "name = ?", "telegram").Error; err != nil {
t.Fatalf("find message sender provider: %v", err)
}
if sender.Addition != `{"bot_token":"old-token"}` {
t.Fatalf("message sender provider was unexpectedly changed: %s", sender.Addition)
}
var task models.PingTask
if err := db.First(&task, "name = ?", "already explicit").Error; err != nil {
t.Fatalf("find ping task: %v", err)
}
if len(task.Clients) != 1 || task.Clients[0] != "client-a" {
t.Fatalf("explicit ping task clients were changed: %v", task.Clients)
}
}
func TestRunMigratesLegacyConfigTableToConfigItems(t *testing.T) {
db := openTestDB(t, "migrations_legacy_config_to_items")
if err := db.AutoMigrate(&legacyModelConfig{}); err != nil {
t.Fatalf("migrate legacy config table: %v", err)
}
legacy := legacyModelConfig{
Sitename: "Old Komari",
Description: "legacy description",
Theme: "classic",
GeoIpEnabled: true,
GeoIpProvider: "ip-api",
OAuthProvider: "github",
NotificationMethod: "none",
TrafficLimitPercentage: 66.5,
RecordEnabled: true,
RecordPreserveTime: 48,
PingRecordPreserveTime: 12,
ExpireNotificationLeadDays: 3,
}
if err := db.Create(&legacy).Error; err != nil {
t.Fatalf("seed legacy config: %v", err)
}
if err := Run(Context{DB: db}); err != nil {
t.Fatalf("run migrations: %v", err)
}
if db.Migrator().HasColumn(&legacyModelConfig{}, "sitename") {
t.Fatal("legacy config columns were not removed")
}
var sitename appconfig.ConfigItem
if err := db.First(&sitename, "key = ?", appconfig.SitenameKey).Error; err != nil {
t.Fatalf("find migrated sitename: %v", err)
}
if sitename.Value != `"Old Komari"` {
t.Fatalf("unexpected sitename value: %s", sitename.Value)
}
var corsOriginCheck appconfig.ConfigItem
if err := db.First(&corsOriginCheck, "key = ?", appconfig.CorsOriginCheckEnabledKey).Error; err == nil {
t.Fatalf("unexpected migrated cors_origin_check_enabled value: %s", corsOriginCheck.Value)
}
}
func TestRunExpandsLegacyPingAllClientsTasks(t *testing.T) {
db := openTestDB(t, "migrations_ping_all_clients")
if err := db.AutoMigrate(&models.Client{}); err != nil {
t.Fatalf("migrate clients: %v", err)
}
if err := db.Exec(`
CREATE TABLE ping_tasks (
id integer primary key autoincrement,
weight integer not null default 0,
name varchar(255) not null,
all_clients boolean not null default false,
type varchar(12) not null default 'icmp',
target varchar(255) not null,
interval integer not null default 60
)
`).Error; err != nil {
t.Fatalf("create legacy ping_tasks: %v", err)
}
now := models.FromTime(time.Now())
clients := []models.Client{
{UUID: "client-a", Token: "token-a", CreatedAt: now, UpdatedAt: now},
{UUID: "client-b", Token: "token-b", CreatedAt: now, UpdatedAt: now},
}
if err := db.Create(&clients).Error; err != nil {
t.Fatalf("seed clients: %v", err)
}
if err := db.Exec("INSERT INTO ping_tasks (name, all_clients, type, target, interval) VALUES (?, ?, ?, ?, ?)", "legacy task", true, "icmp", "example.com", 60).Error; err != nil {
t.Fatalf("seed legacy ping task: %v", err)
}
if err := Run(Context{DB: db}); err != nil {
t.Fatalf("run migrations: %v", err)
}
var task models.PingTask
if err := db.First(&task).Error; err != nil {
t.Fatalf("find migrated ping task: %v", err)
}
if len(task.Clients) != 2 {
t.Fatalf("expected two migrated clients, got %v", task.Clients)
}
got := map[string]bool{}
for _, uuid := range task.Clients {
got[uuid] = true
}
if !got["client-a"] || !got["client-b"] {
t.Fatalf("unexpected migrated clients: %v", task.Clients)
}
raw, err := json.Marshal(task.Clients)
if err != nil {
t.Fatalf("marshal migrated clients: %v", err)
}
if string(raw) != `["client-a","client-b"]` {
t.Fatalf("unexpected clients json: %s", raw)
}
}
+58
View File
@@ -0,0 +1,58 @@
package rpc
// context.go
// 定义 RPC 执行时可附带的上下文元信息。用于在 handler 中获取当前请求的鉴权/主体信息。
// 注意:避免出现循环依赖,这里仅引用 models.User(database/models 未引用本包)。
import (
"context"
"github.com/komari-monitor/komari/database/models"
)
// ContextMeta 保存一次 RPC 调用可用的鉴权/身份元数据。
// 为了便于扩展,字段保持冗余:既保存结构体,也保存对应 UUID / Token。
// 未来如需添加字段(如 IP、UserAgent、TraceID 等)直接在此结构体上扩展即可。
type ContextMeta struct {
// Permission 当前权限分组 guest/client/admin
Permission string
// User 登录的管理员用户(仅 admin 会话存在)
User *models.User
// UserUUID 方便无需解引用就能快速判断
UserUUID string
// ClientToken 来自客户端的私钥 / 鉴权 token(仅 client 链接可能存在)
ClientToken string
// ClientUUID 解析出的客户端 UUID(若 token 合法)
ClientUUID string
// RemoteIP 请求来源 IP(可选)
RemoteIP string
// UserAgent 请求 UA(可选)
UserAgent string
}
// 私有类型做 key,避免外部冲突
type ctxMetaKey struct{}
// NewContextWithMeta 将 meta 写入 context
func NewContextWithMeta(parent context.Context, meta *ContextMeta) context.Context {
if parent == nil {
parent = context.Background()
}
if meta == nil {
return parent
}
return context.WithValue(parent, ctxMetaKey{}, meta)
}
// MetaFromContext 读取 meta;不存在返回 nil
func MetaFromContext(ctx context.Context) *ContextMeta {
if ctx == nil {
return nil
}
if v := ctx.Value(ctxMetaKey{}); v != nil {
if m, ok := v.(*ContextMeta); ok {
return m
}
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package rpc
import "fmt"
// JsonRpcError 错误对象
// Code 按 JSON-RPC 规范使用 -32768 ~ -32000 的预留范围,其它业务自定义可使用正数或自定义区间。
// Message 为简短描述,Data 可携带扩展信息(结构化或字符串)。
type JsonRpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// 预定义错误码 JSON-RPC 2.0 标准
const (
ParseError = -32700
InvalidRequest = -32600
MethodNotFound = -32601
InvalidParams = -32602
InternalError = -32603
)
// Komari 定义错误码
const (
NotFound = -32044
AlreadyExists = -32045
PermissionDenied = -32041
Unauthenticated = -32040 // 未登录/无身份
Cancelled = -32010 // 主动取消
DeadlineExceeded = -32011 // 超时
Aborted = -32021 // 并发冲突/事务中断
OutOfRange = -32022 // 数值/索引越界
Unimplemented = -32050 // 尚未实现
Unavailable = -32051 // 依赖服务暂不可用
DataLoss = -32052 // 不可恢复的数据丢失
)
// MakeError 便捷创建错误对象
func MakeError(code int, msg string, data any) *JsonRpcError {
return &JsonRpcError{Code: code, Message: msg, Data: data}
}
func (e *JsonRpcError) Error() string {
return fmt.Sprintf("JSON-RPC Error %d: %s", e.Code, e.Message)
}
func (e *JsonRpcError) Response() *JsonRpcResponse {
return e.ResponseWithID(nil)
}
func (e *JsonRpcError) ResponseWithID(id any) *JsonRpcResponse {
return &JsonRpcResponse{
Version: RPC_VERSION,
Error: e,
ID: id,
}
}
+91
View File
@@ -0,0 +1,91 @@
package rpc
// internal.go
// 定义并注册保留前缀 "rpc." 的内部方法及其元数据。
import (
"context"
"sort"
"strings"
)
// registerInternal 直接写入保留前缀方法。
// 假定调用点受控(init 阶段),若重复则忽略以防 panic。
func registerInternal(method string, h Handler) {
if !strings.HasPrefix(method, "rpc.") {
method = "rpc." + method
}
muHandlers.Lock()
if _, exists := handlers[method]; !exists {
handlers[method] = h
}
muHandlers.Unlock()
}
// listMethods 返回方法列表;includeInternal=false 时剔除 rpc.*
func listMethods(includeInternal bool) []string {
all := ListMethods()
out := make([]string, 0, len(all))
for _, m := range all {
if !includeInternal && strings.HasPrefix(m, "rpc.") {
continue
}
out = append(out, m)
}
sort.Strings(out)
return out
}
func init() {
// rpc.methods -> 列出方法名
registerInternal("rpc.methods", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
var params struct {
ShowInternal bool `json:"internal"`
}
req.BindParams(&params)
return listMethods(params.ShowInternal), nil
})
// rpc.version -> 协议版本
registerInternal("rpc.version", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
return RPC_VERSION, nil
})
// rpc.ping -> 健康检查
registerInternal("rpc.ping", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
return "pong", nil
})
// rpc.help -> 方法元数据或概览
registerInternal("rpc.help", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
var params struct {
Method string `json:"method"`
}
req.BindParams(&params)
if params.Method != "" {
meta := getMetaUnsafe(params.Method)
if meta == nil {
return nil, MakeError(InvalidRequest, "method not found", nil)
}
return meta, nil
}
return listMetas(true), nil
})
// 元数据注册
RegisterMeta("rpc.methods", &MethodMeta{
Name: "rpc.methods",
Summary: "List methods",
Description: "Return the list of currently callable methods. By default, internal methods are not included. Pass internal=true to include them.",
Params: []ParamMeta{{Name: "internal", Type: "bool", Description: "Whether to include internal rpc.* methods"}},
Returns: "[]string",
})
RegisterMeta("rpc.version", &MethodMeta{Name: "rpc.version", Summary: "Return the RPC version", Returns: "string"})
RegisterMeta("rpc.ping", &MethodMeta{Name: "rpc.ping", Summary: "Health check, returns pong", Returns: "string"})
RegisterMeta("rpc.help", &MethodMeta{
Name: "rpc.help",
Summary: "Get method help",
Description: "Returns detailed metadata for the specified method if given.",
Params: []ParamMeta{
{Name: "method", Type: "string", Description: "Target method name (mutually exclusive with list)"},
},
Returns: "MethodMeta",
})
}
+49
View File
@@ -0,0 +1,49 @@
package rpc
import "context"
// Invoke 便捷调用:构造请求并执行已注册方法,返回 result 或 *JsonRpcError。
// 不生成 JsonRpcResponse。
//
// @ref Call
func Invoke(method string, params any) (any, *JsonRpcError) {
req := &JsonRpcRequest{Version: RPC_VERSION, Method: method, Params: params}
if e := req.Validate(); e != nil {
return nil, e
}
muHandlers.RLock()
h, ok := handlers[method]
muHandlers.RUnlock()
if !ok {
return nil, &JsonRpcError{Code: MethodNotFound, Message: "method not found", Data: method}
}
return h(context.Background(), req)
}
// Call 执行方法并直接返回完整的 JSON-RPC Response。
// 适用于对外暴露:始终返回结构(包括错误)。
// ctx: 执行上下文;id: 请求 id;method/params: 方法与参数。
func Call(id any, method string, params any) *JsonRpcResponse {
return CallWithContext(context.Background(), id, method, params)
}
func CallWithContext(ctx context.Context, id any, method string, params any) *JsonRpcResponse {
if ctx == nil {
ctx = context.Background()
}
req := &JsonRpcRequest{Version: RPC_VERSION, Method: method, Params: params, ID: id}
if e := req.Validate(); e != nil {
return ErrorResponse(id, e.Code, e.Message, e.Data)
}
muHandlers.RLock()
h, ok := handlers[method]
muHandlers.RUnlock()
if !ok {
return ErrorResponse(id, MethodNotFound, "method not found", method)
}
result, jerr := h(ctx, req)
if jerr != nil {
return ErrorResponse(id, jerr.Code, jerr.Message, jerr.Data)
}
return SuccessResponse(id, result)
}
+106
View File
@@ -0,0 +1,106 @@
package rpc
// meta.go
// 为已注册的 RPC 方法维护可选的帮助/描述信息,供 rpc.help 使用。
// 不影响原有 Register 行为;未显式附加元数据时会自动生成仅包含名称的占位。
import (
"sort"
"strings"
"sync"
)
// ParamMeta 描述单个参数。
type ParamMeta struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
Description string `json:"description,omitempty"`
}
// MethodMeta 描述方法的帮助信息。
type MethodMeta struct {
Name string `json:"name"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Params []ParamMeta `json:"params,omitempty"`
Returns string `json:"returns,omitempty"`
Example any `json:"example,omitempty"`
}
var (
muMetas sync.RWMutex
methodMetas = map[string]*MethodMeta{}
)
// getMetaUnsafe 内部使用:调用方需已持有 handlers 的读/写锁或自行同步(此处独立锁保障并发)。
func getMetaUnsafe(name string) *MethodMeta {
muMetas.RLock()
m := methodMetas[name]
muMetas.RUnlock()
return m
}
// ensureMeta 确保存在基本元数据(最少 Name)。
func ensureMeta(name string) {
if name == "" {
return
}
muMetas.Lock()
if _, ok := methodMetas[name]; !ok {
methodMetas[name] = &MethodMeta{Name: name}
}
muMetas.Unlock()
}
// RegisterMeta 为已注册方法附加/覆盖元数据(Name 字段若为空自动填充)。
func RegisterMeta(name string, meta *MethodMeta) {
if name == "" || meta == nil {
return
}
if meta.Name == "" {
meta.Name = name
}
muMetas.Lock()
methodMetas[name] = meta
muMetas.Unlock()
}
// RegisterWithMeta 同时注册方法与元数据;若注册失败返回错误。
func RegisterWithMeta(method string, h Handler, meta *MethodMeta) error {
if err := Register(method, h); err != nil {
return err
}
if meta != nil {
RegisterMeta(method, meta)
} else {
ensureMeta(method)
}
return nil
}
// listMetas 获取所有方法(按给定过滤器)简要元数据的副本。
func listMetas(includeInternal bool) []*MethodMeta {
muHandlers.RLock()
names := make([]string, 0, len(handlers))
for n := range handlers {
names = append(names, n)
}
muHandlers.RUnlock()
out := make([]*MethodMeta, 0, len(names))
for _, n := range names {
if !includeInternal && strings.HasPrefix(n, "rpc.") {
continue
}
if m := getMetaUnsafe(n); m != nil {
out = append(out, &MethodMeta{ // 复制简要字段
Name: m.Name,
Summary: m.Summary,
})
} else {
out = append(out, &MethodMeta{Name: n})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
+57
View File
@@ -0,0 +1,57 @@
package rpc
import (
"bytes"
"encoding/json"
)
// ParseRequest 解析单个 JSON-RPC 请求。返回请求与错误(解析层面)。
func ParseRequest(data []byte) (*JsonRpcRequest, *JsonRpcError) {
requests, err := ParseRequests(data)
if err != nil {
return nil, err
}
if len(requests) == 0 {
return nil, &JsonRpcError{Code: InvalidRequest, Message: "no requests found"}
}
return requests[0], nil
}
// ParseRequests 解析单个或批量 JSON-RPC 请求。返回请求切片与错误(解析层面),
// 若是批量空数组则返回 InvalidRequest 错误(协议要求)。
func ParseRequests(data []byte) ([]*JsonRpcRequest, *JsonRpcError) {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return nil, &JsonRpcError{Code: ParseError, Message: "empty body"}
}
first := data[0]
if first == '{' { // 单个
var r JsonRpcRequest
if err := json.Unmarshal(data, &r); err != nil {
return nil, &JsonRpcError{Code: ParseError, Message: "invalid json", Data: err.Error()}
}
if e := r.Validate(); e != nil {
return nil, e
}
return []*JsonRpcRequest{&r}, nil
}
if first == '[' { // 批量
var arr []JsonRpcRequest
if err := json.Unmarshal(data, &arr); err != nil {
return nil, &JsonRpcError{Code: ParseError, Message: "invalid json", Data: err.Error()}
}
if len(arr) == 0 {
return nil, &JsonRpcError{Code: InvalidRequest, Message: "empty batch"}
}
res := make([]*JsonRpcRequest, 0, len(arr))
for i := range arr {
rr := arr[i]
if e := rr.Validate(); e != nil {
return nil, e
}
res = append(res, &rr)
}
return res, nil
}
return nil, &JsonRpcError{Code: ParseError, Message: "invalid json: not object/array"}
}
+53
View File
@@ -0,0 +1,53 @@
package rpc
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
// Handler 方法签名:返回 result (成功) 或 *JsonRpcError (失败)
type Handler func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError)
var (
muHandlers sync.RWMutex
handlers = map[string]Handler{}
)
// Register 注册方法。重复注册返回错误。保留前缀 "rpc." 禁止外部注册。
func Register(method string, h Handler) error {
method = strings.TrimSpace(method)
if method == "" {
return errors.New("method empty")
}
if strings.HasPrefix(method, "rpc.") {
return errors.New("method prefix 'rpc.' is reserved")
}
muHandlers.Lock()
defer muHandlers.Unlock()
if _, exists := handlers[method]; exists {
return fmt.Errorf("method already registered: %s", method)
}
handlers[method] = h
return nil
}
// MustRegister 便捷注册(panic on error)
func MustRegister(method string, h Handler) {
if err := Register(method, h); err != nil {
panic(err)
}
}
// ListMethods 列出当前已注册的方法名(副本)
func ListMethods() []string {
muHandlers.RLock()
defer muHandlers.RUnlock()
res := make([]string, 0, len(handlers))
for k := range handlers {
res = append(res, k)
}
return res
}
+227
View File
@@ -0,0 +1,227 @@
package rpc
import (
"encoding/json"
"errors"
"reflect"
"strings"
)
// JsonRpcRequest 表示 JSON-RPC 2.0 请求(单个或批量中的一个元素)
// 说明:为了能区分 Notification(无 id),这里将 ID 定义为 any 并打上 omitempty。
// 你可以通过 req.HasID() 判断是否为普通请求(需要返回)还是通知。
type JsonRpcRequest struct {
Version string `json:"jsonrpc"` // 必须 === "2.0"
Method string `json:"method"` // 方法名;以 "rpc." 前缀的为保留内部方法
Params any `json:"params,omitempty"` // 参数(位置数组或命名对象)
ID any `json:"id,omitempty"` // 字符串 / 数值 / null;Notification 时省略
}
// NewRequest 创建一个普通请求(带 id)
func NewRequest(id any, method string, params any) *JsonRpcRequest {
return &JsonRpcRequest{Version: RPC_VERSION, Method: method, Params: params, ID: id}
}
// NewNotification 创建一个 Notification(无 id,不会收到返回)
func NewNotification(method string, params any) *JsonRpcRequest {
return &JsonRpcRequest{Version: RPC_VERSION, Method: method, Params: params}
}
// HasID 判断是否包含 id(Notification 没有 id,不需要返回)
func (r *JsonRpcRequest) HasID() bool { return r != nil && r.ID != nil }
// Validate 校验请求格式合法性(不校验方法是否存在)
func (r *JsonRpcRequest) Validate() *JsonRpcError {
if r == nil {
return &JsonRpcError{Code: InvalidRequest, Message: "invalid request: null"}
}
if r.Version != RPC_VERSION {
return &JsonRpcError{Code: InvalidRequest, Message: "invalid jsonrpc version"}
}
if strings.TrimSpace(r.Method) == "" {
return &JsonRpcError{Code: InvalidRequest, Message: "method required"}
}
return nil
}
// GetParams(兼容旧接口):按名称获取参数(仅 map Object 情况),没有则 target 不变
func (r *JsonRpcRequest) GetParams(name string, target *any) {
if r == nil || r.Params == nil || target == nil {
return
}
if m, ok := r.Params.(map[string]any); ok {
*target = m[name]
}
}
// GetParamAs 获取具名参数并尝试转换为类型 T
func GetParamAs[T any](req *JsonRpcRequest, name string) (val T, ok bool) {
if req == nil || req.Params == nil {
return
}
if m, isMap := req.Params.(map[string]any); isMap {
raw, exists := m[name]
if !exists {
return
}
if v, good := raw.(T); good {
return v, true
}
b, err := json.Marshal(raw)
if err != nil {
return
}
var t T
if err = json.Unmarshal(b, &t); err != nil {
return
}
return t, true
}
return
}
// GetPositionalParamAs 获取位置参数 idx 的值为 T
func GetPositionalParamAs[T any](req *JsonRpcRequest, idx int) (val T, ok bool) {
if req == nil || req.Params == nil {
return
}
if arr, isArr := req.Params.([]any); isArr {
if idx < 0 || idx >= len(arr) {
return
}
raw := arr[idx]
if v, good := raw.(T); good {
return v, true
}
b, err := json.Marshal(raw)
if err != nil {
return
}
var t T
if err = json.Unmarshal(b, &t); err != nil {
return
}
return t, true
}
return
}
// BindParams 将 Params 绑定到给定结构体指针。
// 支持:
// 1. object(map) -> 按字段名反序列化(标准 encoding/json 行为,大小写不敏感)
// 2. array([]any) -> 若 target 是结构体指针,按导出字段声明顺序依次填充;
// 数组更短: 剩余字段保持零值;数组更长: 忽略多余元素。
// 非结构体指针则退回原逻辑整体反序列化。
// 3. 单一标量 -> 若 target 是结构体指针,则赋值给第一个导出字段;否则整体反序列化。
// 4. 其它类型 -> 直接整体反序列化。
func (r *JsonRpcRequest) BindParams(target any) error {
if r == nil {
return errors.New("nil request")
}
if target == nil || reflect.ValueOf(target).Kind() != reflect.Ptr {
return errors.New("target must be pointer")
}
if r.Params == nil {
return nil
}
switch p := r.Params.(type) {
case map[string]any:
b, err := json.Marshal(p)
if err != nil {
return err
}
return json.Unmarshal(b, target)
case []any:
// 特殊处理:struct 指针时按字段顺序映射
rv := reflect.ValueOf(target).Elem()
if rv.Kind() == reflect.Struct {
rt := rv.Type()
ai := 0
for i := 0; i < rt.NumField() && ai < len(p); i++ {
f := rt.Field(i)
if f.PkgPath != "" { // 非导出字段跳过(不占位置)
continue
}
fv := rv.Field(i)
raw := p[ai]
ai++
// 快速路径:可直接赋值
if raw != nil {
val := reflect.ValueOf(raw)
if val.IsValid() {
if val.Type().AssignableTo(fv.Type()) {
fv.Set(val)
continue
}
if val.Type().ConvertibleTo(fv.Type()) {
fv.Set(val.Convert(fv.Type()))
continue
}
}
}
// 回退:通过 JSON 做一次精确转换(处理数字 float64 -> int 等)
b, err := json.Marshal(raw)
if err != nil {
return err
}
tmp := reflect.New(fv.Type())
if err = json.Unmarshal(b, tmp.Interface()); err != nil {
return err
}
fv.Set(tmp.Elem())
}
return nil
}
// 非 struct 情况,退回整体解码
b, err := json.Marshal(p)
if err != nil {
return err
}
return json.Unmarshal(b, target)
default:
// 单一标量到结构体首个导出字段
rv := reflect.ValueOf(target).Elem()
if rv.Kind() == reflect.Struct {
rt := rv.Type()
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
if f.PkgPath != "" { // 非导出
continue
}
fv := rv.Field(i)
raw := p
if raw != nil {
val := reflect.ValueOf(raw)
if val.IsValid() {
if val.Type().AssignableTo(fv.Type()) {
fv.Set(val)
return nil
}
if val.Type().ConvertibleTo(fv.Type()) {
fv.Set(val.Convert(fv.Type()))
return nil
}
}
}
// fallback json 转换
b, err := json.Marshal(raw)
if err != nil {
return err
}
tmp := reflect.New(fv.Type())
if err = json.Unmarshal(b, tmp.Interface()); err != nil {
return err
}
fv.Set(tmp.Elem())
return nil
}
// 无导出字段
return nil
}
b, err := json.Marshal(p)
if err != nil {
return err
}
return json.Unmarshal(b, target)
}
}
+30
View File
@@ -0,0 +1,30 @@
package rpc
// JsonRpcResponse JSON-RPC 2.0 响应
// 成功时包含 result,失败时包含 error;二者互斥。
// 在 Notification 情况下服务器不会发送任何响应。
type JsonRpcResponse struct {
Version string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *JsonRpcError `json:"error,omitempty"`
}
// SuccessResponse 构造成功响应
func SuccessResponse(id any, result any) *JsonRpcResponse {
return &JsonRpcResponse{Version: RPC_VERSION, ID: id, Result: result}
}
// ErrorResponse 构造失败响应
func ErrorResponse(id any, code int, msg string, data any) *JsonRpcResponse {
return &JsonRpcResponse{Version: RPC_VERSION, ID: id, Error: &JsonRpcError{Code: code, Message: msg, Data: data}}
}
// InternalErrorResponse 统一内部错误
func InternalErrorResponse(id any, err error) *JsonRpcResponse {
msg := "internal error"
if err != nil {
msg = err.Error()
}
return ErrorResponse(id, InternalError, msg, nil)
}
+5
View File
@@ -0,0 +1,5 @@
package rpc
const (
RPC_VERSION = "2.0"
)
+108
View File
@@ -0,0 +1,108 @@
package rpc
import (
"context"
"testing"
)
// helper to fetch handler (test only)
func getHandler(name string) Handler {
muHandlers.RLock()
defer muHandlers.RUnlock()
return handlers[name]
}
// 重复注册
func TestRegisterDuplicateAndReserved(t *testing.T) {
name := "sample.method"
// first register should succeed
if err := Register(name, func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) { return "ok", nil }); err != nil {
t.Fatalf("first Register failed: %v", err)
}
// duplicate register should error
if err := Register(name, func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) { return "ok", nil }); err == nil {
t.Fatalf("expected duplicate register error")
}
// reserved prefix
if err := Register("rpc.test", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) { return nil, nil }); err == nil {
t.Fatalf("expected reserved prefix error")
}
}
// 内部函数
func TestInternalMethods(t *testing.T) {
res, err := Invoke("rpc.ping", nil)
if err != nil {
t.Fatalf("rpc.ping returned error: %+v", err)
}
if res != "pong" {
t.Fatalf("expected pong got %v", res)
}
// version
res, err = Invoke("rpc.version", nil)
if err != nil {
t.Fatalf("rpc.version returned error: %+v", err)
}
if res != RPC_VERSION {
t.Fatalf("expected version %s got %v", RPC_VERSION, res)
}
}
// Test rpc.help functionality
func TestRpcHelp(t *testing.T) {
// Register a method with metadata
method := "demo.echo"
RegisterWithMeta(method, func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
v, _ := GetParamAs[string](req, "v")
return v, nil
}, &MethodMeta{Summary: "echo back string", Description: "Returns the provided string parameter v", Params: []ParamMeta{{Name: "v", Type: "string", Required: false, Description: "value to echo"}}, Returns: "string"})
// Query single method
resp := Call(1, "rpc.help", map[string]any{"method": method})
if resp.Error != nil {
t.Fatalf("rpc.help single error: %+v", resp.Error)
}
resp = Call(2, "rpc.help", "rpc.help")
if resp.Error != nil {
t.Fatalf("rpc.help multiple error: %+v", resp.Error)
}
resp = Call(3, "rpc.help", nil)
if resp.Error != nil {
t.Fatalf("rpc.help nil params error: %+v", resp.Error)
}
}
// Test BindParams positional array -> struct mapping
func TestBindParamsPositionalStruct(t *testing.T) {
type Pair struct {
First int
Second int
}
req := &JsonRpcRequest{Version: RPC_VERSION, Method: "x", Params: []any{1, 2}}
var p Pair
if err := req.BindParams(&p); err != nil {
t.Fatalf("BindParams failed: %v", err)
}
if p.First != 1 || p.Second != 2 {
t.Fatalf("unexpected struct values: %+v", p)
}
// shorter array
req2 := &JsonRpcRequest{Version: RPC_VERSION, Method: "x", Params: []any{7}}
p = Pair{}
if err := req2.BindParams(&p); err != nil {
t.Fatalf("BindParams short failed: %v", err)
}
if p.First != 7 || p.Second != 0 {
t.Fatalf("expected (7,0) got %+v", p)
}
// longer array (extra ignored)
req3 := &JsonRpcRequest{Version: RPC_VERSION, Method: "x", Params: []any{9, 8, 100}}
p = Pair{}
if err := req3.BindParams(&p); err != nil {
t.Fatalf("BindParams long failed: %v", err)
}
if p.First != 9 || p.Second != 8 {
t.Fatalf("expected (9,8) got %+v", p)
}
}
+87
View File
@@ -0,0 +1,87 @@
package v1
import "time"
type Message struct {
Type string `json:"type"`
Content string `json:"content"`
Sender string `json:"sender"`
Timestamp int64 `json:"timestamp"`
}
type IPAddress struct {
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
}
type Report struct {
UUID string `json:"uuid,omitempty"`
CPU CPUReport `json:"cpu"`
Ram RamReport `json:"ram"`
Swap RamReport `json:"swap"`
Load LoadReport `json:"load"`
Disk DiskReport `json:"disk"`
Network NetworkReport `json:"network"`
Connections ConnectionsReport `json:"connections"`
GPU *GPUDetailReport `json:"gpu,omitempty"`
Uptime int64 `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
Method string `json:"method,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type CPUReport struct {
Name string `json:"name,omitempty"`
Cores int `json:"cores,omitempty"`
Arch string `json:"arch,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type GPUDetailReport struct {
Count int `json:"count"`
AverageUsage float64 `json:"average_usage"`
DetailedInfo []GPUDeviceInfo `json:"detailed_info"`
}
type GPUDeviceInfo struct {
Name string `json:"name"`
MemoryTotal int64 `json:"memory_total"`
MemoryUsed int64 `json:"memory_used"`
Utilization float64 `json:"utilization"`
Temperature int `json:"temperature"`
}
// GPUReport is the legacy v1 single-GPU shape kept for wire compatibility.
type GPUReport struct {
Name string `json:"name,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type RamReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type LoadReport struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
type DiskReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type NetworkReport struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
TotalUp int64 `json:"totalUp"`
TotalDown int64 `json:"totalDown"`
}
type ConnectionsReport struct {
TCP int `json:"tcp"`
UDP int `json:"udp"`
}
+101
View File
@@ -0,0 +1,101 @@
package v2
import v1 "github.com/komari-monitor/komari/protocol/v1"
const (
Version = "2.0"
MethodAgentReport = "agent.report"
MethodAgentBasicInfo = "agent.basicInfo"
MethodAgentPingResult = "agent.pingResult"
MethodAgentTaskResult = "agent.taskResult"
MethodAgentExec = "agent.exec"
MethodAgentPing = "agent.ping"
MethodAgentMessage = "agent.message"
MethodAgentEvent = "agent.event"
MethodAgentTerminal = "agent.terminal.request"
MethodAgentPull = "agent.pull"
)
type Request struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
ID any `json:"id,omitempty"`
}
type Response struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
type Event struct {
ID string `json:"id"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
type ReportParams struct {
Report v1.Report `json:"report"`
AckEventIDs []string `json:"ack_event_ids,omitempty"`
}
type BasicInfoParams struct {
Info map[string]interface{} `json:"info"`
}
type PingResultParams struct {
TaskID uint `json:"task_id"`
PingType string `json:"ping_type"`
Value int `json:"value"`
FinishedAt string `json:"finished_at"`
}
type PullParams struct {
Capabilities []string `json:"capabilities,omitempty"`
AckEventIDs []string `json:"ack_event_ids,omitempty"`
LastEventID string `json:"last_event_id,omitempty"`
}
type ExecParams struct {
TaskID string `json:"task_id"`
Command string `json:"command"`
}
type PingParams struct {
TaskID uint `json:"ping_task_id"`
Type string `json:"ping_type"`
Target string `json:"ping_target"`
}
type MessageParams struct {
Type string `json:"type"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
type EventParams struct {
Type string `json:"type"`
Data any `json:"data,omitempty"`
}
type TerminalRequestParams struct {
RequestID string `json:"request_id"`
}
func Success(id any, result any) Response {
return Response{JSONRPC: Version, ID: id, Result: result}
}
func Error(id any, code int, message string, data any) Response {
return Response{JSONRPC: Version, ID: id, Error: &RPCError{Code: code, Message: message, Data: data}}
}
+124
View File
@@ -0,0 +1,124 @@
package v2
import "encoding/json"
const (
MethodNetworkTestNextTrace = "networkTest.nextTrace"
MethodNetworkTestIperf3 = "networkTest.iperf3"
MethodNetworkTestMeshTrace = "networkTest.meshTrace"
MethodNetworkTestGetMeshTraceJob = "networkTest.getMeshTraceJob"
)
type IPFamily string
const (
IPFamilyAuto IPFamily = "auto"
IPFamilyIPv4 IPFamily = "ipv4"
IPFamilyIPv6 IPFamily = "ipv6"
)
type TraceProtocol string
const (
TraceProtocolICMP TraceProtocol = "icmp"
TraceProtocolTCP TraceProtocol = "tcp"
TraceProtocolUDP TraceProtocol = "udp"
)
type MeshMode string
const (
MeshModeAllToAll MeshMode = "all_to_all"
MeshModeOneToAll MeshMode = "one_to_all"
MeshModePairs MeshMode = "pairs"
)
type NodeEndpoint struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
Host string `json:"host"`
Family IPFamily `json:"ip_family"`
}
type NextTraceParams struct {
TaskID string `json:"task_id"`
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
TargetHost string `json:"target_host"`
IPFamily IPFamily `json:"ip_family"`
Protocol TraceProtocol `json:"protocol"`
MaxHops int `json:"max_hops"`
TimeoutMs int `json:"timeout_ms"`
RawOutput bool `json:"raw_output"`
}
type NextTraceResult struct {
TaskID string `json:"task_id"`
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
TargetHost string `json:"target_host"`
IPFamily IPFamily `json:"ip_family"`
Protocol TraceProtocol `json:"protocol"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
DurationMs int `json:"duration_ms"`
OK bool `json:"ok"`
Error string `json:"error"`
Summary TraceSummary `json:"summary"`
Hops []TraceHop `json:"hops"`
Raw json.RawMessage `json:"raw"`
RawText string `json:"raw_text"`
ExitCode int `json:"exit_code"`
Truncated bool `json:"truncated"`
}
type TraceSummary struct {
HopCount int `json:"hop_count"`
Reached bool `json:"reached"`
PacketLoss float64 `json:"packet_loss"`
RTTMs float64 `json:"rtt_ms"`
}
type TraceHop struct {
Hop int `json:"hop"`
Host string `json:"host"`
IP string `json:"ip"`
RTTMs float64 `json:"rtt_ms"`
Loss float64 `json:"loss"`
ASN string `json:"asn"`
Location string `json:"location"`
}
type MeshTraceParams struct {
SourceNodeIDs []string `json:"source_node_ids"`
TargetNodeIDs []string `json:"target_node_ids"`
Mode MeshMode `json:"mode"`
IPFamily IPFamily `json:"ip_family"`
Protocol TraceProtocol `json:"protocol"`
MaxHops int `json:"max_hops"`
TimeoutMs int `json:"timeout_ms"`
MaxConcurrency int `json:"max_concurrency"`
PerAgentLimit int `json:"per_agent_limit"`
}
type MeshTraceAccepted struct {
JobID string `json:"job_id"`
Status string `json:"status"`
TotalPairs int `json:"total_pairs"`
AcceptedAt string `json:"accepted_at"`
PollIntervalMs int `json:"poll_interval_ms"`
}
type MeshTraceJobSnapshot struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Total int `json:"total"`
Done int `json:"done"`
Failed int `json:"failed"`
Running int `json:"running"`
Results []NextTraceResult `json:"results"`
StartedAt string `json:"started_at"`
UpdatedAt string `json:"updated_at"`
FinishedAt string `json:"finished_at"`
Error string `json:"error"`
}
+317
View File
@@ -0,0 +1,317 @@
package v2
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestNetworkTestMethodConstants(t *testing.T) {
tests := map[string]string{
MethodNetworkTestNextTrace: "networkTest.nextTrace",
MethodNetworkTestIperf3: "networkTest.iperf3",
MethodNetworkTestMeshTrace: "networkTest.meshTrace",
MethodNetworkTestGetMeshTraceJob: "networkTest.getMeshTraceJob",
}
for got, want := range tests {
if got != want {
t.Fatalf("method constant = %q, want %q", got, want)
}
}
}
func TestNextTraceParamsJSON(t *testing.T) {
params := NextTraceParams{
TaskID: "trace-1",
SourceID: "agent-a",
TargetID: "agent-b",
TargetHost: "203.0.113.10",
IPFamily: IPFamilyIPv4,
Protocol: TraceProtocolICMP,
MaxHops: 30,
TimeoutMs: 5000,
RawOutput: true,
}
data, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, data, []string{
"task_id",
"source_id",
"target_id",
"target_host",
"ip_family",
"protocol",
"max_hops",
"timeout_ms",
"raw_output",
})
assertNoCamelCaseKeys(t, data)
var decoded NextTraceParams
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, params) {
t.Fatalf("decoded params = %#v, want %#v", decoded, params)
}
}
func TestNextTraceResultJSON(t *testing.T) {
result := NextTraceResult{
TaskID: "trace-1",
SourceID: "agent-a",
TargetID: "agent-b",
TargetHost: "203.0.113.10",
IPFamily: IPFamilyIPv4,
Protocol: TraceProtocolICMP,
StartedAt: "2026-06-05T10:00:00Z",
FinishedAt: "2026-06-05T10:00:01Z",
DurationMs: 1000,
OK: true,
Error: "",
Summary: TraceSummary{
HopCount: 2,
Reached: true,
PacketLoss: 0,
RTTMs: 12.5,
},
Hops: []TraceHop{
{Hop: 1, Host: "router", IP: "192.0.2.1", RTTMs: 1.2, Loss: 0, ASN: "AS64500", Location: "lab"},
{Hop: 2, Host: "target", IP: "203.0.113.10", RTTMs: 12.5, Loss: 0, ASN: "AS64501", Location: "edge"},
},
Raw: json.RawMessage(`{"parser":"nexttrace"}`),
RawText: "raw trace text",
ExitCode: 0,
Truncated: false,
}
data, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, data, []string{
"task_id",
"source_id",
"target_id",
"target_host",
"ip_family",
"protocol",
"started_at",
"finished_at",
"duration_ms",
"ok",
"error",
"summary",
"hops",
"raw",
"raw_text",
"exit_code",
"truncated",
})
assertJSONKeysAt(t, data, "summary", []string{"hop_count", "reached", "packet_loss", "rtt_ms"})
assertJSONKeysAt(t, data, "hops.0", []string{"hop", "host", "ip", "rtt_ms", "loss", "asn", "location"})
assertNoCamelCaseKeys(t, data)
var decoded NextTraceResult
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, result) {
t.Fatalf("decoded result = %#v, want %#v", decoded, result)
}
}
func TestMeshTraceParamsJSON(t *testing.T) {
params := MeshTraceParams{
SourceNodeIDs: []string{"agent-a"},
TargetNodeIDs: []string{"agent-b", "agent-c"},
Mode: MeshModeOneToAll,
IPFamily: IPFamilyIPv6,
Protocol: TraceProtocolTCP,
MaxHops: 20,
TimeoutMs: 8000,
MaxConcurrency: 4,
PerAgentLimit: 2,
}
data, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, data, []string{
"source_node_ids",
"target_node_ids",
"mode",
"ip_family",
"protocol",
"max_hops",
"timeout_ms",
"max_concurrency",
"per_agent_limit",
})
assertNoCamelCaseKeys(t, data)
var decoded MeshTraceParams
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, params) {
t.Fatalf("decoded params = %#v, want %#v", decoded, params)
}
}
func TestMeshTraceJobSnapshotJSON(t *testing.T) {
snapshot := MeshTraceJobSnapshot{
JobID: "mesh-1",
Status: "running",
Total: 2,
Done: 1,
Failed: 0,
Running: 1,
Results: []NextTraceResult{
{
TaskID: "trace-1",
SourceID: "agent-a",
TargetID: "agent-b",
TargetHost: "203.0.113.10",
IPFamily: IPFamilyIPv4,
Protocol: TraceProtocolUDP,
StartedAt: "2026-06-05T10:00:00Z",
FinishedAt: "2026-06-05T10:00:01Z",
DurationMs: 1000,
OK: false,
Error: "timeout",
Summary: TraceSummary{HopCount: 0, Reached: false, PacketLoss: 1, RTTMs: 0},
Hops: []TraceHop{},
Raw: json.RawMessage(`null`),
RawText: "",
ExitCode: 1,
Truncated: false,
},
},
StartedAt: "2026-06-05T10:00:00Z",
UpdatedAt: "2026-06-05T10:00:01Z",
FinishedAt: "",
Error: "",
}
data, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, data, []string{
"job_id",
"status",
"total",
"done",
"failed",
"running",
"results",
"started_at",
"updated_at",
"finished_at",
"error",
})
assertJSONKeysAt(t, data, "results.0", []string{"task_id", "source_id", "target_id", "target_host"})
assertNoCamelCaseKeys(t, data)
var decoded MeshTraceJobSnapshot
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, snapshot) {
t.Fatalf("decoded snapshot = %#v, want %#v", decoded, snapshot)
}
}
func assertJSONKeys(t *testing.T, data []byte, keys []string) {
t.Helper()
var object map[string]any
if err := json.Unmarshal(data, &object); err != nil {
t.Fatal(err)
}
for _, key := range keys {
if _, ok := object[key]; !ok {
t.Fatalf("missing JSON key %q in %s", key, data)
}
}
}
func assertJSONKeysAt(t *testing.T, data []byte, path string, keys []string) {
t.Helper()
var root any
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
object := jsonObjectAt(t, root, path)
for _, key := range keys {
if _, ok := object[key]; !ok {
t.Fatalf("missing JSON key %q at %q in %s", key, path, data)
}
}
}
func assertNoCamelCaseKeys(t *testing.T, data []byte) {
t.Helper()
var value any
if err := json.Unmarshal(data, &value); err != nil {
t.Fatal(err)
}
var walk func(any)
walk = func(current any) {
switch typed := current.(type) {
case map[string]any:
for key, next := range typed {
if strings.ContainsAny(key, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
t.Fatalf("JSON key %q contains uppercase characters in %s", key, data)
}
walk(next)
}
case []any:
for _, next := range typed {
walk(next)
}
}
}
walk(value)
}
func jsonObjectAt(t *testing.T, root any, path string) map[string]any {
t.Helper()
current := root
for _, part := range strings.Split(path, ".") {
switch typed := current.(type) {
case map[string]any:
current = typed[part]
case []any:
if part != "0" {
t.Fatalf("unsupported array path segment %q", part)
}
current = typed[0]
default:
t.Fatalf("path %q reached non-container value %#v", path, current)
}
}
object, ok := current.(map[string]any)
if !ok {
t.Fatalf("path %q is not a JSON object: %#v", path, current)
}
return object
}
-1
View File
@@ -1 +0,0 @@
dist/*
-120
View File
@@ -1,120 +0,0 @@
package public
import (
"embed"
"errors"
"fmt"
"html"
"io"
"io/fs"
"log"
"net/http"
"os" // added for file existence check
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
)
//go:embed dist
var PublicFS embed.FS
var DistFS fs.FS
var RawIndexFile string
var IndexFile string
func initIndex() {
dist, err := fs.Sub(PublicFS, "dist")
if err != nil {
log.Println("Failed to create dist subdirectory:", err)
}
DistFS = dist
indexFile, err := dist.Open("index.html")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
log.Println("index.html not exist, you may forget to put dist of frontend to public/dist")
}
log.Println("Failed to open index.html:", err)
}
defer func() {
_ = indexFile.Close()
}()
index, err := io.ReadAll(indexFile)
if err != nil {
log.Println("Failed to read index.html:", err)
}
RawIndexFile = string(index)
}
func UpdateIndex(cfg models.Config) {
var titleReplacement string
if cfg.Sitename == "Komari" {
titleReplacement = "<title>Komari Monitor</title>"
} else {
titleReplacement = fmt.Sprintf("<title>%s - Komari Monitor</title>", html.EscapeString(cfg.Sitename))
}
replaceMap := map[string]string{
"<title>Komari Monitor</title>": titleReplacement,
"A simple server monitor tool.": cfg.Description,
"</head>": cfg.CustomHead + "</head>",
"</body>": cfg.CustomBody + "</body>",
}
updated := RawIndexFile
for k, v := range replaceMap {
updated = strings.Replace(updated, k, v, -1)
}
IndexFile = 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 {
c.File("data/favicon.ico")
return
}
f, err := DistFS.Open("favicon.ico")
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, "image/x-icon", data)
})
noRoute(func(c *gin.Context) {
if c.Request.Method != "GET" && c.Request.Method != "POST" {
c.Status(405)
return
}
c.Header("Content-Type", "text/html")
c.Status(200)
c.Writer.WriteString(IndexFile)
c.Writer.Flush()
c.Writer.WriteHeaderNow()
})
}
-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)
+337 -100
View File
@@ -2,105 +2,153 @@ package cloudflared
import (
"bufio"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/secureconfig"
)
func downloadCloudflared(downloadURL, filePath string) error {
log.Println("downloading cloudflared...")
resp, err := http.Get(downloadURL)
type RuntimeStatus struct {
Installed bool `json:"installed"`
Running bool `json:"running"`
Message string `json:"message"`
ErrorMessage string `json:"errorMessage"`
Logs []string `json:"logs"`
PID int `json:"pid,omitempty"`
BinaryPath string `json:"binaryPath,omitempty"`
TokenStored bool `json:"tokenStored"`
EnvTokenPresent bool `json:"envTokenPresent"`
}
type manager struct {
mu sync.RWMutex
cmd *exec.Cmd
done chan struct{}
running bool
stopRequested bool
message string
errorMessage string
logs []string
maxLogLines int
}
var defaultManager = &manager{
maxLogLines: 80,
message: "cloudflared is not running",
}
func Status() RuntimeStatus {
return defaultManager.status()
}
func Start(token string) error {
return defaultManager.start(token)
}
func Stop() error {
return defaultManager.stop()
}
func Shutdown() {
if err := defaultManager.stop(); err != nil {
log.Printf("failed to stop cloudflared: %v", err)
}
}
func SaveToken(token string) error {
token = strings.TrimSpace(token)
if token == "" {
return config.Set(config.CloudflareTunnelTokenKey, "")
}
encrypted, err := secureconfig.EncryptString(token)
if err != nil {
return err
}
defer resp.Body.Close()
out, err := os.Create(filePath)
if err != nil {
return err
return config.Set(config.CloudflareTunnelTokenKey, encrypted)
}
func RemoveToken() error {
if defaultManager.status().Running {
return errors.New("stop cloudflared before removing the token")
}
_, err = io.Copy(out, resp.Body)
closeErr := out.Close() // 先关闭文件
if err != nil {
return err
return config.Set(config.CloudflareTunnelTokenKey, "")
}
func LoadToken() (token string, err error) {
if envToken := strings.TrimSpace(os.Getenv("KOMARI_CLOUDFLARED_TOKEN")); envToken != "" {
return envToken, nil
}
if closeErr != nil {
return closeErr
}
if runtime.GOOS != "windows" {
err = os.Chmod(filePath, 0755)
return loadStoredToken()
}
func AutoStart(envToken string) error {
token := strings.TrimSpace(envToken)
if token == "" {
var err error
token, err = LoadToken()
if err != nil {
return err
}
} else {
if err := SaveToken(token); err != nil {
return err
}
log.Println("[cloudflared] using token from KOMARI_CLOUDFLARED_TOKEN")
}
if token == "" {
return nil
}
if err := Start(token); err != nil {
defaultManager.setError(err.Error())
return err
}
return nil
}
var cloudflaredCmd *exec.Cmd
func (m *manager) start(token string) error {
m.mu.Lock()
defer m.mu.Unlock()
func RunCloudflared() error {
var (
downloadURL string
fileName = "cloudflared"
)
if _, err := os.Stat("data"); os.IsNotExist(err) {
os.Mkdir("data", 0755)
if m.running {
return errors.New("cloudflared is already running")
}
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 strings.TrimSpace(token) == "" {
var err error
token, err = LoadToken()
if err != nil {
return err
return fmt.Errorf("failed to load Cloudflare Tunnel token: %w", err)
}
}
if strings.TrimSpace(token) == "" {
return errors.New("cloudflare tunnel token is not configured")
}
args := []string{"tunnel", "--no-autoupdate", "run", "--token"}
token := os.Getenv("KOMARI_CLOUDFLARED_TOKEN")
args = append(args, token)
binaryPath, installed := resolveBinaryPath()
if !installed {
return errors.New("cloudflared is not installed; install it manually or use the Docker image with built-in cloudflared")
}
cmd := exec.Command(binaryPath, "tunnel", "--no-autoupdate", "run")
cmd.Env = append(os.Environ(), "TUNNEL_TOKEN="+token)
cmd := exec.Command(filePath, args...)
cloudflaredCmd = cmd
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
@@ -109,41 +157,230 @@ func RunCloudflared() error {
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")
m.cmd = cmd
m.done = make(chan struct{})
m.running = true
m.stopRequested = false
m.errorMessage = ""
m.message = "cloudflared is running"
m.appendLogLocked(fmt.Sprintf("started cloudflared (pid=%d)", cmd.Process.Pid))
go m.scanPipe(stdout, false)
go m.scanPipe(stderr, true)
go m.waitProcess(cmd, m.done)
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")
func (m *manager) stop() error {
m.mu.RLock()
cmd := m.cmd
done := m.done
running := m.running
m.mu.RUnlock()
if !running || cmd == nil || cmd.Process == nil {
return nil
}
m.mu.Lock()
m.stopRequested = true
m.message = "stopping cloudflared..."
m.mu.Unlock()
var signalErr error
if runtime.GOOS == "windows" {
signalErr = cmd.Process.Kill()
} else {
signalErr = cmd.Process.Signal(syscall.SIGTERM)
}
if signalErr != nil {
return signalErr
}
if done != nil {
select {
case <-done:
case <-time.After(10 * time.Second):
_ = cmd.Process.Kill()
select {
case <-done:
case <-time.After(2 * time.Second):
}
}
cloudflaredCmd = nil
}
return nil
}
func (m *manager) waitProcess(cmd *exec.Cmd, done chan struct{}) {
err := cmd.Wait()
m.mu.Lock()
defer m.mu.Unlock()
if err != nil {
if m.stopRequested {
m.message = "cloudflared stopped"
m.errorMessage = ""
m.appendLogLocked("cloudflared stopped")
} else {
m.message = "cloudflared exited unexpectedly"
m.errorMessage = err.Error()
m.appendLogLocked("cloudflared exited unexpectedly: " + err.Error())
}
} else {
m.message = "cloudflared stopped"
if !m.stopRequested {
m.errorMessage = ""
}
m.appendLogLocked("cloudflared stopped")
}
m.cmd = nil
m.running = false
m.stopRequested = false
close(done)
}
func (m *manager) scanPipe(reader io.ReadCloser, isErr bool) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
m.mu.Lock()
if isErr || looksLikeError(line) {
m.errorMessage = line
}
m.message = line
m.appendLogLocked(line)
m.mu.Unlock()
}
}
func (m *manager) status() RuntimeStatus {
binaryPath, installed := resolveBinaryPath()
m.mu.RLock()
defer m.mu.RUnlock()
status := RuntimeStatus{
Installed: installed,
Running: m.running,
Message: m.message,
ErrorMessage: m.errorMessage,
Logs: append([]string{}, m.logs...),
BinaryPath: binaryPath,
EnvTokenPresent: strings.TrimSpace(os.Getenv("KOMARI_CLOUDFLARED_TOKEN")) != "",
}
if m.cmd != nil && m.cmd.Process != nil {
status.PID = m.cmd.Process.Pid
}
token, err := loadStoredToken()
if err == nil && strings.TrimSpace(token) != "" {
status.TokenStored = true
}
return status
}
func (m *manager) setError(message string) {
m.mu.Lock()
defer m.mu.Unlock()
m.errorMessage = message
m.message = message
m.appendLogLocked(message)
}
func (m *manager) appendLogLocked(line string) {
m.logs = append(m.logs, line)
if len(m.logs) > m.maxLogLines {
m.logs = m.logs[len(m.logs)-m.maxLogLines:]
}
}
func loadStoredToken() (token string, err error) {
defer func() {
if recover() != nil {
token = ""
err = nil
}
}()
raw, err := config.GetAs[string](config.CloudflareTunnelTokenKey, "")
if err != nil {
return "", err
}
if strings.TrimSpace(raw) == "" {
return "", nil
}
token, err = secureconfig.DecryptString(raw)
if err == nil {
return token, nil
}
return raw, nil
}
func resolveBinaryPath() (string, bool) {
candidates := []string{}
if envPath := strings.TrimSpace(os.Getenv("KOMARI_CLOUDFLARED_BIN")); envPath != "" {
candidates = append(candidates, envPath)
}
candidates = append(candidates, "cloudflared")
if runtime.GOOS == "windows" {
candidates = append(candidates,
filepath.Join("data", "cloudflared.exe"),
filepath.Join("data", "bin", "cloudflared.exe"),
)
} else {
candidates = append(candidates,
filepath.Join("data", "cloudflared"),
filepath.Join("data", "bin", "cloudflared"),
"/usr/local/bin/cloudflared",
"/usr/bin/cloudflared",
)
}
seen := map[string]struct{}{}
for _, candidate := range candidates {
if candidate == "" {
continue
}
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
if path, err := exec.LookPath(candidate); err == nil {
return path, true
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
if abs, absErr := filepath.Abs(candidate); absErr == nil {
return abs, true
}
return candidate, true
}
}
return "", false
}
func looksLikeError(line string) bool {
lower := strings.ToLower(line)
return strings.Contains(lower, "error") ||
strings.Contains(lower, "failed") ||
strings.Contains(lower, "invalid") ||
strings.Contains(lower, "unauthorized")
}
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more