Compare commits

..
108 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
194 changed files with 12271 additions and 3045 deletions

No files matched your search

+12 -5
View File
@@ -65,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
@@ -107,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 }}*
+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
+11 -4
View File
@@ -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();
+11 -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
+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
+2 -1
View File
@@ -33,4 +33,5 @@ go.work.sum
.env
# build
komari
komari
.codex
+13 -2
View File
@@ -6,7 +6,18 @@ WORKDIR /app
ARG TARGETOS
ARG TARGETARCH
RUN apk add --no-cache tzdata
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
@@ -24,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.
+24 -12
View File
@@ -1,6 +1,7 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/komari-monitor/komari)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
@@ -8,9 +9,10 @@
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
[Documentation](https://komari-document.pages.dev/) | [Telegram Group](https://t.me/komari_monitor)
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
## Features
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
- **Self-hosted**: Complete control over data privacy, easy to deploy.
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
@@ -18,16 +20,19 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
## 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_)
- Rainyun - CNY 4.5/month
- Claw Run - USD 1.5/month
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
- 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
@@ -35,6 +40,7 @@ sudo ./install-komari.sh
```
### 2. Docker Deployment
1. Create a data directory:
```bash
mkdir -p ./data
@@ -57,6 +63,7 @@ sudo ./install-komari.sh
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
### 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
@@ -68,7 +75,6 @@ sudo ./install-komari.sh
> [!NOTE]
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
### Manual Build
#### Dependencies
@@ -87,34 +93,41 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
```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. Run:
3. Run:
```bash
./komari server -l 0.0.0.0:25774
```
The default listening port is `25774`. Access `http://localhost:25774`.
## Frontend Development Guide
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
## 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)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
@@ -127,4 +140,3 @@ All the developers who submitted PRs and created themes
## 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)
-42
View File
@@ -1,42 +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) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
// session-based authentication
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
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()
}
}
-157
View File
@@ -1,157 +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/config"
"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
var gpuRecords []models.GPURecord
// 遍历所有客户端记录
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)
// 使用与其他数据相同的聚合逻辑处理GPU数据
gpuAggregated := utils.AverageGPUReports(uuid, time.Now(), filtered, 0.3)
gpuRecords = append(gpuRecords, gpuAggregated...)
}
}
// 批量插入数据库前去重(client与time共同构成唯一键)
db := dbcore.GetDBInstance()
if len(records) > 0 {
unique := make(map[string]models.Record)
for _, rec := range records {
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)
}
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
log.Printf("Failed to save records to database: %v", err)
return err
}
}
// 批量插入GPU记录
if len(gpuRecords) > 0 {
// GPU记录也需要去重,防止重复插入
gpuUnique := make(map[string]models.GPURecord)
for _, rec := range gpuRecords {
key := rec.Client + "_" + strconv.Itoa(rec.DeviceIndex) + "_" + strconv.FormatInt(rec.Time.ToTime().Unix(), 10)
gpuUnique[key] = rec
}
var gpuDeduped []models.GPURecord
for _, rec := range gpuUnique {
gpuDeduped = append(gpuDeduped, rec)
}
if err := db.Model(&models.GPURecord{}).Create(&gpuDeduped).Error; err != nil {
log.Printf("Failed to save GPU records to database: %v", err)
return err
}
}
return nil
}
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,
})
}
func isApiKeyValid(apiKey string) bool {
cfg, err := config.Get()
if err != nil {
return false
}
if cfg.ApiKey == "" || len(cfg.ApiKey) < 12 {
return false
}
return apiKey == "Bearer "+cfg.ApiKey
}
-75
View File
@@ -1,75 +0,0 @@
package api
import (
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/gin-gonic/gin"
)
var (
publicPaths = []string{
"/ping",
"/api/public",
"/api/login",
"/api/me",
"/api/oauth",
"/api/oauth_callback",
"/api/version",
"/api/recent",
"/api/admin", // 由AdminAuthMiddleware处理
"/api/clients/", // 由TokenAuthMiddleware处理
}
)
func PrivateSiteMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
// 如果是公开的路径,直接放行
for _, path := range publicPaths {
if len(c.Request.URL.Path) >= len(path) && c.Request.URL.Path[:len(path)] == path {
c.Next()
return
}
}
// 如果不是 /api,直接放行
if len(c.Request.URL.Path) < 4 || c.Request.URL.Path[:4] != "/api" {
c.Next()
return
}
conf, err := config.Get()
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
return
}
// 验证私有, 如果不是私有站点,直接放行
if !conf.PrivateSite {
c.Next()
return
}
// 如果是私有站点,检查是否有 session
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
return
}
_, err = accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
c.Next()
}
}
-89
View File
@@ -1,89 +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) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
var token string
// Step 1: Check query parameter for token
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
}
-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)
}
-85
View File
@@ -1,85 +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 getClientIPType(ip net.IP) int {
// 0:ipv4 1:ipv6 -1:错误的输入
if ip == nil {
return -1
}
if ip.To4() == nil {
return 1
} else {
return 0
}
}
func UploadBasicInfo(c *gin.Context) {
var cbi = map[string]interface{}{}
if err := c.ShouldBindJSON(&cbi); err != nil {
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 (func() bool {
if v4, ok := cbi["ipv4"].(string); !ok || v4 == "" {
if v6, ok := cbi["ipv6"].(string); !ok || v6 == "" {
return true
}
}
return false
})() {
ipStr := c.ClientIP()
ip := net.ParseIP(ipStr)
ipType := getClientIPType(ip)
switch ipType {
case 0:
cbi["ipv4"] = ip
case 1:
cbi["ipv6"] = ip
default:
break
}
}
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
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"})
}
-77
View File
@@ -1,77 +0,0 @@
package api
import (
"encoding/json"
"io"
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/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 != "" { // 开启了2FA
if data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
auditlog.Log(c.ClientIP(), uuid, "logged in (password)", "login")
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
accounts.DeleteSession(session)
c.SetCookie("session_token", "", -1, "/", "", false, true)
auditlog.Log(c.ClientIP(), "", "logged out", "logout")
c.Redirect(302, "/")
}
-15
View File
@@ -1,15 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database"
)
func GetPublicSettings(c *gin.Context) {
p, e := database.GetPublicInfo()
if e != nil {
RespondError(c, 500, e.Error())
return
}
RespondSuccess(c, p)
}
-38
View File
@@ -1,38 +0,0 @@
package task
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
)
type PublicPingTask struct {
Id uint `json:"id"`
Name string `json:"name"`
Clients []string `json:"clients"`
Type string `json:"type"`
Interval int `json:"interval"`
}
func GetPublicPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
publicTasks := make([]PublicPingTask, len(tasks))
for i, task := range tasks {
publicTasks[i] = PublicPingTask{
Id: task.Id,
Name: task.Name,
Clients: task.Clients,
Type: task.Type,
Interval: task.Interval,
}
}
api.RespondSuccess(c, publicTasks)
}
-167
View File
@@ -1,167 +0,0 @@
package api
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/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: ws.CheckOrigin,
}
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()
})
//auditlog.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
}
auditlog.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()
auditlog.Log(session.RequesterIp, session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
}
+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)
}
}
+76 -267
View File
@@ -7,46 +7,37 @@ import (
"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/jsonRpc"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/api/task"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/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/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"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"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/utils/oauth"
"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",
@@ -65,10 +56,6 @@ func init() {
func RunServer() {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
// 创建主题目录
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
log.Fatalf("Failed to create theme directory: %v", err)
}
@@ -76,7 +63,7 @@ func RunServer() {
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
conf, err := config.Get()
conf, err := config.GetManyAs[config.Settings]()
if err != nil {
log.Fatal(err)
}
@@ -88,7 +75,7 @@ func RunServer() {
if conf.NezhaCompatEnabled {
go func() {
if err := StartNezhaCompat(conf.NezhaCompatListen); err != nil {
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))
}
@@ -96,8 +83,11 @@ func RunServer() {
}
config.Subscribe(func(event config.ConfigEvent) {
if event.New.OAuthProvider != event.Old.OAuthProvider {
oidcProvider, err := database.GetOidcConfigByName(event.New.OAuthProvider)
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 {
@@ -108,17 +98,16 @@ func RunServer() {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
messageSender.Initialize()
}
if event.New.NezhaCompatEnabled != event.Old.NezhaCompatEnabled {
if event.New.NezhaCompatEnabled {
if err := StartNezhaCompat(event.New.NezhaCompatListen); err != nil {
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 := StopNezhaCompat(); err != nil {
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))
}
@@ -127,43 +116,27 @@ func RunServer() {
})
// 初始化 cloudflared
if strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
if err != nil {
log.Fatalf("Failed to run cloudflared: %v", err)
}
if err := cloudflared.AutoStart(GetEnv("KOMARI_CLOUDFLARED_TOKEN", "")); err != nil {
log.Printf("failed to auto start cloudflared: %v", err)
}
r := gin.Default()
r := gin.New()
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
// 动态 CORS 中间件
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
DynamicCorsEnabled = event.New.AllowCors
if event.New.GeoIpProvider != event.Old.GeoIpProvider {
if event.IsChanged(config.GeoIpProviderKey) {
go geoip.InitGeoIp()
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
if event.IsChanged(config.NotificationMethodKey) {
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(security.CorsMiddleware(conf.CorsOriginCheckEnabled, conf.CorsAllowedOrigins))
r.Use(api.IdentityMiddleware())
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
@@ -173,172 +146,7 @@ func RunServer() {
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", api.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)
r.GET("/api/task/ping", task.GetPublicPingTasks)
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
settingsGroup := adminAuthrized.Group("/settings")
{
settingsGroup.GET("/", admin.GetSettings)
settingsGroup.POST("/", admin.EditSettings)
settingsGroup.POST("/oidc", admin.SetOidcProvider)
settingsGroup.GET("/oidc", admin.GetOidcProvider)
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
}
// themes
themeGroup := adminAuthrized.Group("/theme")
{
themeGroup.PUT("/upload", admin.UploadTheme)
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
loadAlertGroup := notificationGroup.Group("/load")
{
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
loadAlertGroup.POST("/add", notification.AddLoadNotification)
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
}
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
// #region 静态文件服务
public.UpdateIndex(conf)
config.Subscribe(func(event config.ConfigEvent) {
public.UpdateIndex(event.New)
})
router.Register(r)
srv := &http.Server{
Addr: flags.Listen,
@@ -364,18 +172,6 @@ func RunServer() {
}
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()
@@ -388,41 +184,54 @@ func InitDatabase() {
// #region 定时任务
func DoScheduledWork() {
tasks.ReloadPingSchedule()
d_notification.ReloadLoadNotificationSchedule()
ticker := time.NewTicker(time.Minute * 30)
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
go notifier.CheckExpireScheduledWork()
for {
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
auditlog.RemoveOldLogs()
case <-minute.C:
api.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
// 每分钟检查一次流量提醒
go notifier.CheckTraffic()
}
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() {
auditlog.Log("", "", "server is shutting down", "info")
cloudflared.Kill()
corn.StopAll()
cloudflared.Shutdown()
}
func OnFatal(err error) {
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
cloudflared.Kill()
cloudflared.Shutdown()
}
-135
View File
@@ -1,135 +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"`
GPU *GPUDetailReport `json:"gpu,omitempty"` // 新增GPU详细信息
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"`
}
// GPUDetailReport 详细GPU信息报告
type GPUDetailReport struct {
Count int `json:"count"` // GPU数量
AverageUsage float64 `json:"average_usage"` // 平均使用率
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
}
// GPUDeviceInfo 单个GPU设备信息
type GPUDeviceInfo struct {
Name string `json:"name"` // GPU型号名称
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
Utilization float64 `json:"utilization"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// 保持向后兼容的旧GPUReport结构
type GPUReport struct {
Name string `json:"name,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
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"`
}
+26 -15
View File
@@ -6,10 +6,10 @@ import (
"net"
"time"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
@@ -39,21 +39,23 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
LoginMethod: login_method,
LatestOnline: models.FromTime(time.Now()),
}
cfg, _ := config.Get()
if cfg.LoginNotification {
ipAddr := net.ParseIP(ip)
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
loc := "unknown"
if ipinfo != nil {
loc = ipinfo.Name
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: "🔑",
})
}
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 {
@@ -131,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
}
+75 -122
View File
@@ -1,30 +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
@@ -34,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 || cbi.CpuCores < math.MaxInt-1 {
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)
@@ -121,27 +39,73 @@ func SaveClientInfo(update map[string]interface{}) error {
update["updated_at"] = time.Now()
checkInt64 := func(name string, val float64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
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
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
}
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 update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
}
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
if err := checkOptionalInt("Cpu.Cores", "cpu_cores", math.MaxInt-1); err != nil {
return err
}
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
if err := checkOptionalInt("Cpu.PhysicalCores", "cpu_physical_cores", math.MaxInt-1); err != nil {
return err
}
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
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
@@ -158,16 +122,6 @@ func SaveClientInfo(update map[string]interface{}) error {
return nil
}
// 更新客户端设置
func UpdateClientConfig(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
err := db.Save(&config).Error
if err != nil {
return err
}
return nil
}
func EditClientName(clientUUID, clientName string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
@@ -177,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
@@ -219,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
}
@@ -241,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
}
@@ -309,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
+64 -9
View File
@@ -2,17 +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) {
@@ -40,20 +81,20 @@ 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 common.Report) error {
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")
@@ -137,11 +178,14 @@ func ReportVerify(report common.Report) error {
}
// 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: %v", err)
return fmt.Errorf("failed to save Record: %w", err)
}
// 保存GPU详细记录到独立表
@@ -159,7 +203,7 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
Temperature: gpu.Temperature,
}
if err := db.Create(&gpuRecord).Error; err != nil {
return fmt.Errorf("failed to save GPU record: %v", err)
return fmt.Errorf("failed to save GPU record: %w", err)
}
}
}
@@ -187,6 +231,8 @@ 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,
@@ -195,9 +241,18 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
// 使用事务确保 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"
"sync"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm"
)
var mu sync.Mutex
func init() {
mu = sync.Mutex{}
}
func Get() (models.Config, error) {
mu.Lock()
defer mu.Unlock()
db := dbcore.GetDBInstance()
var config models.Config
if err := db.First(&config).Error; err != nil {
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: "ipinfo",
NezhaCompatEnabled: false,
NezhaCompatListen: "",
NotificationTemplate: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
UpdatedAt: models.FromTime(time.Now()),
CreatedAt: models.FromTime(time.Now()),
}
if err := db.Create(&config).Error; err != nil {
log.Fatal("Failed to create default config:", err)
}
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())
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
Select("*").
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)")
}
// 没绑定账号也不能禁用
if newDisablePasswordLogin {
usr := &models.User{}
if err := db.Model(&models.User{}).First(usr).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve user"))
}
if usr.SSOID == "" {
return errors.New("cannot disable password login when no SSO-bound account exists")
}
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
return errors.Join(err, errors.New("failed to update configuration"))
}
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)
}
}
+27 -218
View File
@@ -2,7 +2,6 @@ package dbcore
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
@@ -13,13 +12,12 @@ import (
"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"
)
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
@@ -167,193 +165,6 @@ func unzipToDir(zipPath, dstDir string) error {
return nil
}
// 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
}
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记录
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 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
}
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
}
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)
}
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")
}
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
db.Migrator().DropTable(&models.LoadNotification{})
}
if !db.Migrator().HasTable(&models.OidcProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Merge OidcProvider table....")
var config struct {
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for OIDC provider migration:", err)
}
db.AutoMigrate(&models.OidcProvider{})
j, err := json.Marshal(&map[string]string{
"client_id": config.OAuthClientID,
"client_secret": config.OAuthClientSecret,
})
if err != nil {
log.Println("Failed to marshal OIDC provider config:", err)
return
}
db.Save(&models.OidcProvider{
Name: "github",
Addition: string(j),
})
db.AutoMigrate(&models.Config{})
db.Model(&models.Config{}).Where("id = 1").Update("o_auth_provider", "github")
}
if !db.Migrator().HasTable(&models.MessageSenderProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Migrate MessageSender configuration....")
var config struct {
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255)"`
EmailHost string `json:"email_host" gorm:"type:varchar(255)"`
EmailPort int `json:"email_port" gorm:"type:int"`
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"`
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"`
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"`
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"`
EmailUseSSL bool `json:"email_use_ssl" gorm:"type:boolean"`
NotificationMethod string `json:"notification_method" gorm:"type:varchar(50)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for MessageSender migration:", err)
}
db.AutoMigrate(&models.MessageSenderProvider{})
// 迁移Telegram配置
if config.NotificationMethod == "telegram" && config.TelegramBotToken != "" {
telegramConfig := map[string]interface{}{
"bot_token": config.TelegramBotToken,
"chat_id": config.TelegramChatID,
"endpoint": config.TelegramEndpoint,
}
if telegramConfig["endpoint"] == "" {
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
}
telegramConfigJSON, err := json.Marshal(telegramConfig)
if err != nil {
log.Println("Failed to marshal Telegram config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "telegram",
Addition: string(telegramConfigJSON),
})
}
}
// 迁移Email配置
if config.NotificationMethod == "email" && config.EmailHost != "" {
emailConfig := map[string]interface{}{
"host": config.EmailHost,
"port": config.EmailPort,
"username": config.EmailUsername,
"password": config.EmailPassword,
"sender": config.EmailSender,
"receiver": config.EmailReceiver,
"use_ssl": config.EmailUseSSL,
}
emailConfigJSON, err := json.Marshal(emailConfig)
if err != nil {
log.Println("Failed to marshal Email config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "email",
Addition: string(emailConfigJSON),
})
}
}
// 删除旧的配置字段
if db.Migrator().HasColumn(&models.Config{}, "telegram_bot_token") {
db.Migrator().DropColumn(&models.Config{}, "telegram_bot_token")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_chat_id") {
db.Migrator().DropColumn(&models.Config{}, "telegram_chat_id")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_endpoint") {
db.Migrator().DropColumn(&models.Config{}, "telegram_endpoint")
}
if db.Migrator().HasColumn(&models.Config{}, "email_host") {
db.Migrator().DropColumn(&models.Config{}, "email_host")
}
if db.Migrator().HasColumn(&models.Config{}, "email_port") {
db.Migrator().DropColumn(&models.Config{}, "email_port")
}
if db.Migrator().HasColumn(&models.Config{}, "email_username") {
db.Migrator().DropColumn(&models.Config{}, "email_username")
}
if db.Migrator().HasColumn(&models.Config{}, "email_password") {
db.Migrator().DropColumn(&models.Config{}, "email_password")
}
if db.Migrator().HasColumn(&models.Config{}, "email_sender") {
db.Migrator().DropColumn(&models.Config{}, "email_sender")
}
if db.Migrator().HasColumn(&models.Config{}, "email_receiver") {
db.Migrator().DropColumn(&models.Config{}, "email_receiver")
}
if db.Migrator().HasColumn(&models.Config{}, "email_use_ssl") {
db.Migrator().DropColumn(&models.Config{}, "email_use_ssl")
}
}
}
var (
instance *gorm.DB
once sync.Once
@@ -361,6 +172,7 @@ var (
func GetDBInstance() *gorm.DB {
once.Do(func() {
var err error
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
@@ -408,18 +220,12 @@ func GetDBInstance() *gorm.DB {
}()
logConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
if utils.VersionHash == "unknown" {
logConfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
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 {
@@ -430,35 +236,28 @@ func GetDBInstance() *gorm.DB {
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
}
instance.Exec("VACUUM;")
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)
}
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.GPURecord{},
&models.Config{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.TrafficReportNotification{},
&models.PingRecord{},
&models.PingTask{},
&models.OidcProvider{},
@@ -494,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
}
-42
View File
@@ -1,42 +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"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
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"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt LocalTime
UpdatedAt LocalTime
}
@@ -8,4 +8,7 @@ const (
Login = "Login"
Alert = "Alert"
Traffic = "Traffic"
DReport = "DReport" // 日报
WReport = "WReport" // 周报
MReport = "MReport" // 月报
)
+26 -11
View File
@@ -15,6 +15,7 @@ type Client struct {
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)"`
@@ -87,6 +88,8 @@ type Record struct {
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"`
@@ -95,14 +98,14 @@ type Record struct {
// 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)
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
@@ -110,9 +113,21 @@ type GPURecord 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)
}
})
}
}
+10
View File
@@ -21,3 +21,13 @@ type LoadNotification struct {
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"` // 月报
}
+22 -6
View File
@@ -9,11 +9,27 @@ type PingRecord struct {
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
}
+115 -4
View File
@@ -1,5 +1,18 @@
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"` // 主题名称
@@ -13,23 +26,121 @@ type Theme struct {
}
type Configuration struct {
Type string `json:"type"` // managed
Type string `json:"type"` // managed raw redirect
Icon string `json:"icon"` // 图标
Name string `json:"name"`
Name any `json:"name"`
Data any `json:"data"` // 配置数据
}
type ManagedThemeConfigurationItem struct {
Key string `json:"key"`
Name string `json:"name"`
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 string `json:"help"`
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)
}
})
}
}
+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)
}
+67 -48
View File
@@ -41,9 +41,9 @@ func DeleteAll() error {
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) {
@@ -57,7 +57,7 @@ func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.G
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
@@ -172,22 +172,27 @@ func CompactRecord() error {
return err
}
if flags.DatabaseType == "sqlite" {
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming database: %v", 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
}
@@ -197,24 +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
Uptime []int64
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)
@@ -238,6 +248,13 @@ 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)
@@ -331,8 +348,10 @@ 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),
@@ -352,7 +371,7 @@ 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
}
@@ -362,18 +381,18 @@ func migrateOldRecords(db *gorm.DB) error {
// migrateGPURecords 压缩GPU记录数据
func migrateGPURecords(db *gorm.DB) error {
fourHoursAgo := time.Now().Add(-4 * time.Hour)
cutoff := compactRecordCutoff(time.Now())
// 查询超过4小时的GPU记录
var gpuRecords []models.GPURecord
if err := db.Where("time < ?", fourHoursAgo).Find(&gpuRecords).Error; err != nil {
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
@@ -381,16 +400,16 @@ func migrateGPURecords(db *gorm.DB) error {
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,
@@ -398,18 +417,18 @@ func migrateGPURecords(db *gorm.DB) error {
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 {
@@ -457,7 +476,7 @@ func migrateGPURecords(db *gorm.DB) error {
}
return float32(getPercentile(floats, percentile))
}
// 保持与传统Record压缩的一致性
high_percentile := 0.7
@@ -480,10 +499,10 @@ func migrateGPURecords(db *gorm.DB) error {
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 = ?",
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
}
@@ -494,12 +513,12 @@ func migrateGPURecords(db *gorm.DB) error {
}
}
}
// 删除已压缩的原始GPU数据
if err := tx.Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
if err := tx.Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
return err
}
return nil
})
}
+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)
}
+116 -9
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,15 +77,54 @@ 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
@@ -93,6 +162,44 @@ func ReloadPingSchedule() error {
return utils.ReloadPingSchedule(pingTasks)
}
// 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
+48 -4
View File
@@ -8,16 +8,60 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
)
func GetPublicInfo() (any, error) {
cst, err := config.Get()
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
@@ -90,7 +134,7 @@ func GetPublicInfo() (any, error) {
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
+22 -10
View File
@@ -1,5 +1,5 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](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)
@@ -9,6 +9,7 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
## 特徴
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
@@ -16,16 +17,19 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
## クイックスタート
### 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_)
- Rainyun - CNY 4.5/月
- Claw Run - USD 1.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
- 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
@@ -33,6 +37,7 @@ sudo ./install-komari.sh
```
### 2. Docker展開
1. データディレクトリを作成します:
```bash
mkdir -p ./data
@@ -55,6 +60,7 @@ sudo ./install-komari.sh
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
### 3. バイナリファイル展開
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komariを実行します:
```bash
@@ -66,7 +72,6 @@ sudo ./install-komari.sh
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
### 手動ビルド
#### 依存関係
@@ -85,36 +90,43 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
```bash
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/web/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/web/public/defaultTheme` にコピーします。
```bash
go build -o komari
```
4. 実行:
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)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
+21 -11
View File
@@ -1,5 +1,5 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](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)
@@ -9,6 +9,7 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
## 特性
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
- **自託管**:完全掌控數據隱私,部署簡單。
- **Web 介面**:直觀的監控儀表盤,易於使用。
@@ -17,16 +18,18 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
### 0. 容器雲一鍵部署
- 雨雲雲應用 - CNY 4.5/月
- 雨雲雲應用 - 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_)
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
- 1Panel 應用商店
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
已上架 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
@@ -34,6 +37,7 @@ sudo ./install-komari.sh
```
### 2. Docker 部署
1. 建立資料目錄:
```bash
mkdir -p ./data
@@ -56,6 +60,7 @@ sudo ./install-komari.sh
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
### 3. 二進位檔案部署
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
2. 執行 Komari:
```bash
@@ -67,7 +72,6 @@ sudo ./install-komari.sh
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
### 手工建置
#### 依賴
@@ -86,36 +90,43 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
```bash
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/web/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
4. 執行:
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)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
@@ -130,4 +141,3 @@ SharonNetworks 為您的業務起飛保駕護航!
## 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)
+22 -11
View File
@@ -1,14 +1,15 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](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)
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
@@ -17,16 +18,18 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
### 0. 容器云一键部署
- 雨云云应用 - CNY 4.5/月
- 雨云云应用 - 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_)
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
- 1Panel 应用商店
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
已上架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
@@ -34,6 +37,7 @@ sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
@@ -56,6 +60,7 @@ sudo ./install-komari.sh
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
@@ -67,7 +72,6 @@ sudo ./install-komari.sh
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
### 手工构建
#### 依赖
@@ -86,36 +90,43 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
```bash
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/web/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
4. 运行:
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)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
+11 -7
View File
@@ -1,9 +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
@@ -12,12 +16,11 @@ require (
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/image v0.35.0
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
)
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
@@ -29,26 +32,27 @@ require golang.org/x/crypto 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/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
@@ -63,7 +67,7 @@ require (
golang.org/x/arch v0.15.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.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
+18 -11
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=
@@ -17,6 +17,12 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
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=
@@ -37,9 +43,8 @@ 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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -47,6 +52,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
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=
@@ -93,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=
@@ -133,6 +139,8 @@ 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.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=
@@ -140,8 +148,8 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
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=
@@ -155,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=
+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)
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
+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/*
-309
View File
@@ -1,309 +0,0 @@
package public
import (
"embed"
"errors"
"fmt"
"html"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
"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() {
err := os.MkdirAll("./data/theme", 0755)
if err != nil {
log.Println("Failed to create theme directory:", err)
return
}
dist, err := fs.Sub(PublicFS, "dist")
if err != nil {
log.Println("Failed to create dist subdirectory:", err)
}
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) {
IndexFile = applyCustomizations(RawIndexFile, cfg)
}
// applyCustomizations 应用自定义内容到HTML字符串
func applyCustomizations(htmlContent string, cfg models.Config) string {
var titleReplacement string
if cfg.Sitename == "Komari" {
titleReplacement = "<title>Komari Monitor</title>"
} 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 := htmlContent
for k, v := range replaceMap {
updated = strings.Replace(updated, k, v, -1)
}
return updated
}
func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
initIndex()
// 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)
})
// r.GET("/manifest.json", func(c *gin.Context) {
// cfg, err := config.Get()
// if cfg.Theme == "default" || cfg.Theme == "" || err != nil {
// // 使用默认主题的manifest.json
// f, err := DistFS.Open("manifest.json")
// if err != nil {
// c.Status(http.StatusNotFound)
// return
// }
// defer f.Close()
// data, err := io.ReadAll(f)
// if err != nil {
// c.Status(http.StatusInternalServerError)
// return
// }
// c.Data(http.StatusOK, "application/json", data)
// } else {
// // 使用自定义主题的manifest.json
// themePath := filepath.Join("./data/theme", cfg.Theme, "dist", "manifest.json")
// if _, err := os.Stat(themePath); os.IsNotExist(err) {
// c.JSON(http.StatusNotFound, gin.H{"error": "Manifest not found"})
// } else {
// c.File(themePath)
// }
// }
// })
// Serve theme files from data/theme directory (for theme previews and static assets)
r.Static("/themes", "./data/theme")
// 使用传入的noRoute函数来处理未匹配的路由
noRoute(func(c *gin.Context) {
path := c.Request.URL.Path
// 对于admin页面,直接使用embedded文件
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
serveFromEmbedded(c, path)
return
}
// 获取当前主题配置
cfg, err := config.Get()
if err != nil || cfg.Theme == "default" || cfg.Theme == "" {
// 使用默认主题(embedded文件)
serveFromEmbedded(c, path)
return
}
// 使用自定义主题
serveFromTheme(c, path, cfg.Theme)
})
}
// serveFromEmbedded 从嵌入的文件系统服务文件
func serveFromEmbedded(c *gin.Context, path string) {
// 处理静态资源文件夹
// folders := []string{"assets", "images", "streamer", "static"}
// for _, folder := range folders {
// if strings.HasPrefix(path, "/"+folder+"/") {
// c.Header("Cache-Control", "public, max-age=15552000")
// sub, err := fs.Sub(DistFS, folder)
// if err == nil {
// relativePath := strings.TrimPrefix(path, "/"+folder+"/")
// file, err := sub.Open(relativePath)
// if err == nil {
// defer file.Close()
// data, err := io.ReadAll(file)
// if err == nil {
// // 设置正确的Content-Type
// contentType := getContentType(path)
// c.Header("Content-Type", contentType)
// c.Data(http.StatusOK, contentType, data)
// return
// }
// }
// }
// }
// }
// 如果文件存在,直接返回文件内容
cleanPath := strings.TrimPrefix(path, "/")
file, err := DistFS.Open(cleanPath)
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
contentType := getContentType(path)
c.Header("Content-Type", contentType)
// 静态资源设置缓存
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
c.Header("Cache-Control", "public, max-age=15552000")
}
c.Data(http.StatusOK, contentType, data)
return
}
}
// 处理HTML页面
if c.Request.Method != "GET" && c.Request.Method != "POST" {
c.Status(405)
return
}
c.Header("Content-Type", "text/html")
c.Status(200)
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
c.Writer.WriteString(RawIndexFile)
} else {
c.Writer.WriteString(IndexFile)
}
c.Writer.Flush()
c.Writer.WriteHeaderNow()
}
// serveFromTheme 从主题目录服务文件
func serveFromTheme(c *gin.Context, path string, themeName string) {
themeDir := filepath.Join("./data/theme", themeName, "dist")
// 构建完整的文件路径
var filePath string
if path == "/" || path == "" {
filePath = filepath.Join(themeDir, "index.html")
} else {
filePath = filepath.Join(themeDir, strings.TrimPrefix(path, "/"))
}
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// 如果文件不存在,尝试serve index.html(用于SPA路由)
if !strings.Contains(path, ".") {
indexPath := filepath.Join(themeDir, "index.html")
if _, err := os.Stat(indexPath); err == nil {
serveThemeIndexWithCustomizations(c, indexPath)
return
}
}
// 回退到默认主题
serveFromEmbedded(c, path)
return
}
// 设置缓存头
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
c.Header("Cache-Control", "public, max-age=15552000")
}
// 如果是index.html文件,需要处理自定义内容
if strings.HasSuffix(filePath, "index.html") {
serveThemeIndexWithCustomizations(c, filePath)
return
}
c.File(filePath)
}
// serveThemeIndexWithCustomizations 服务主题的index.html并应用自定义内容
func serveThemeIndexWithCustomizations(c *gin.Context, indexPath string) {
// 读取主题的index.html文件
data, err := os.ReadFile(indexPath)
if err != nil {
c.Status(http.StatusNotFound)
return
}
// 获取配置以应用自定义内容
cfg, err := config.Get()
if err != nil {
// 如果获取配置失败,直接返回原始文件
c.Header("Content-Type", "text/html")
c.Data(http.StatusOK, "text/html", data)
return
}
// 使用通用的自定义内容应用函数
content := applyCustomizations(string(data), cfg)
c.Header("Content-Type", "text/html")
c.Data(http.StatusOK, "text/html", []byte(content))
}
// getContentType 根据文件扩展名返回Content-Type
func getContentType(path string) string {
if strings.HasSuffix(path, ".css") {
return "text/css"
} else if strings.HasSuffix(path, ".js") {
return "application/javascript"
} else if strings.HasSuffix(path, ".png") {
return "image/png"
} else if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
return "image/jpeg"
} else if strings.HasSuffix(path, ".gif") {
return "image/gif"
} else if strings.HasSuffix(path, ".svg") {
return "image/svg+xml"
} else if strings.HasSuffix(path, ".ico") {
return "image/x-icon"
} else if strings.HasSuffix(path, ".woff") {
return "font/woff"
} else if strings.HasSuffix(path, ".woff2") {
return "font/woff2"
}
return "application/octet-stream"
}
+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")
}
+16 -9
View File
@@ -1,21 +1,28 @@
package cloudflared_test
import (
"os"
"encoding/json"
"strings"
"testing"
"time"
"github.com/komari-monitor/komari/utils/cloudflared"
)
func TestRunCloudflared(t *testing.T) {
os.Setenv("KOMARI_CLOUDFLARED_TOKEN", "test-token")
func TestStatusDoesNotExposeToken(t *testing.T) {
const token = "test-cloudflare-token"
t.Setenv("KOMARI_CLOUDFLARED_TOKEN", token)
err := cloudflared.RunCloudflared()
if err != nil {
t.Fatalf("RunCloudflared failed: %v", err)
status := cloudflared.Status()
if !status.EnvTokenPresent {
t.Fatalf("expected environment token to be detected")
}
// 等待一段时间,确保子进程已启动
time.Sleep(2 * time.Second)
payload, err := json.Marshal(status)
if err != nil {
t.Fatalf("failed to marshal status: %v", err)
}
if strings.Contains(string(payload), token) {
t.Fatalf("expected status payload not to contain the raw token")
}
}
+20 -17
View File
@@ -1,13 +1,13 @@
package geoip
import (
"log"
"log/slog"
"net"
"strings"
"time"
"unicode"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/pkg/config"
"github.com/patrickmn/go-cache"
)
@@ -52,60 +52,63 @@ func GetRegionUnicodeEmoji(isoCode string) string {
}
func InitGeoIp() {
conf, err := config.Get()
conf, err := config.GetMany(map[string]any{
config.GeoIpEnabledKey: true,
config.GeoIpProviderKey: "ipinfo",
})
if err != nil {
panic("Failed to get configuration for GeoIP: " + err.Error())
}
if !conf.GeoIpEnabled {
if !conf[config.GeoIpEnabledKey].(bool) {
return
}
switch conf.GeoIpProvider {
switch conf[config.GeoIpProviderKey].(string) {
case "mmdb":
NewCurrentProvider, err := NewMaxMindGeoIPService()
if err != nil {
log.Printf("Failed to initialize MaxMind GeoIP service: " + err.Error())
slog.Error("Failed to initialize MaxMind GeoIP service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize MaxMind GeoIP service, using EmptyProvider instead.")
slog.Error("Failed to initialize MaxMind GeoIP service, using EmptyProvider instead.")
}
case "ip-api":
NewCurrentProvider, err := NewIPAPIService()
if err != nil {
log.Printf("Failed to initialize ip-api service: " + err.Error())
slog.Error("Failed to initialize ip-api service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using ip-api.com as GeoIP provider.")
slog.Info("Using ip-api.com as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize ip-api service, using EmptyProvider instead.")
slog.Info("Failed to initialize ip-api service, using EmptyProvider instead.")
}
case "geojs":
NewCurrentProvider, err := NewGeoJSService()
if err != nil {
log.Printf("Failed to initialize GeoJS service: " + err.Error())
slog.Error("Failed to initialize GeoJS service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using geojs.io as GeoIP provider.")
slog.Info("Using geojs.io as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize GeoJS service, using EmptyProvider instead.")
slog.Info("Failed to initialize GeoJS service, using EmptyProvider instead.")
}
case "ipinfo":
NewCurrentProvider, err := NewIPInfoService()
if err != nil {
log.Printf("Failed to initialize IPInfo service: " + err.Error())
slog.Error("Failed to initialize IPInfo service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using ipinfo.io as GeoIP provider.")
slog.Info("Using ipinfo.io as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize IPInfo service, using EmptyProvider instead.")
slog.Info("Failed to initialize IPInfo service, using EmptyProvider instead.")
}
default:
CurrentProvider = &EmptyProvider{}
@@ -133,7 +136,7 @@ func UpdateDatabase() error {
err := CurrentProvider.UpdateDatabase()
if err == nil {
geoCache.Flush()
log.Println("GeoIP cache cleared due to database update.")
slog.Info("GeoIP cache cleared due to database update.")
}
return err
}
+13 -1
View File
@@ -17,11 +17,13 @@ func Parse(v any) []Item {
val = val.Elem()
}
var allowTypes = []string{"option", "richtext"}
var items []Item
for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i)
typ := field.Tag.Get("type")
if typ != "option" {
if !contains(allowTypes, typ) {
typ = field.Type.Name()
}
item := Item{
@@ -39,3 +41,13 @@ func Parse(v any) []Item {
}
return items
}
// contains reports whether s is present in slice.
func contains(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
+78
View File
@@ -0,0 +1,78 @@
package log
import (
"fmt"
"log/slog"
"time"
"github.com/gin-gonic/gin"
)
// GinLogger 返回一个 gin.HandlerFunc,用于记录 HTTP 请求
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
// 处理请求
c.Next()
// 计算延迟
latency := time.Since(start)
// 获取状态码
statusCode := c.Writer.Status()
statusCodeColored := func(code int) string {
if code >= 500 {
return Red("%d", code)
} else if code >= 400 {
return Yellow("%d", code)
} else if code >= 300 {
return Cyan("%d", code)
} else {
return Green("%d", code)
}
}(statusCode)
msg := fmt.Sprintf("%s %s %s", statusCodeColored, c.Request.Method, path)
if query != "" {
msg += "?" + query
}
msg += fmt.Sprintf(" | %s | %s", c.ClientIP(), latency)
if len(c.Errors) > 0 {
msg += " | " + c.Errors.String()
}
handler := slog.Default().Handler()
var level slog.Level
if statusCode >= 500 {
level = slog.LevelError
} else {
level = slog.LevelInfo
}
r := slog.NewRecord(time.Now(), level, msg, 0)
r.AddAttrs(slog.String("_group", "GIN")) // 添加分组标识
handler.Handle(c.Request.Context(), r)
}
}
// GinRecovery 返回一个 gin.HandlerFunc,用于恢复 panic
func GinRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
msg := fmt.Sprintf("panic recovered: %v | %s %s", err, c.Request.Method, c.Request.URL.Path)
handler := slog.Default().Handler()
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
r.AddAttrs(slog.String("_group", "GIN"))
handler.Handle(c.Request.Context(), r)
c.AbortWithStatus(500)
}
}()
c.Next()
}
}
+93
View File
@@ -0,0 +1,93 @@
package log
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
komari_utils "github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"gorm.io/gorm/utils"
)
// GormLogger 实现 gorm.io/gorm/logger.Interface
type GormLogger struct {
SlowThreshold time.Duration
IgnoreRecordNotFoundError bool
LogLevel gormlogger.LogLevel
}
// NewGormLogger 创建 GORM logger
func NewGormLogger() *GormLogger {
return &GormLogger{
SlowThreshold: 200 * time.Millisecond,
IgnoreRecordNotFoundError: true,
LogLevel: func(hash string) gormlogger.LogLevel {
if hash == "unknown" {
return gormlogger.Info
}
return gormlogger.Silent
}(komari_utils.VersionHash),
}
}
func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
newlogger := *l
newlogger.LogLevel = level
return &newlogger
}
func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Info {
slog.InfoContext(ctx, fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Warn {
slog.WarnContext(ctx, fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Error {
slog.ErrorContext(ctx, fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.LogLevel <= gormlogger.Silent {
return
}
elapsed := time.Since(begin)
sql, rows := fc()
fileWithLineNum := utils.FileWithLineNum()
handler := slog.Default().Handler()
switch {
case err != nil && l.LogLevel >= gormlogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | ERROR: %v %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, err, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormlogger.Warn:
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | SLOW QUERY %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now(), slog.LevelWarn, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
case l.LogLevel >= gormlogger.Info:
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now(), slog.LevelDebug, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
}
}
+180
View File
@@ -0,0 +1,180 @@
package log
import (
"context"
"fmt"
"io"
stdlog "log"
"log/slog"
"os"
"runtime"
"time"
)
func Green(format string, v ...interface{}) string {
return fmt.Sprintf("\033[32m"+format+"\033[0m", v...)
}
func Yellow(format string, v ...interface{}) string {
return fmt.Sprintf("\033[33m"+format+"\033[0m", v...)
}
func Red(format string, v ...interface{}) string {
return fmt.Sprintf("\033[31m"+format+"\033[0m", v...)
}
func Blue(format string, v ...interface{}) string {
return fmt.Sprintf("\033[34m"+format+"\033[0m", v...)
}
func Cyan(format string, v ...interface{}) string {
return fmt.Sprintf("\033[36m"+format+"\033[0m", v...)
}
func Gray(format string, v ...interface{}) string {
return fmt.Sprintf("\033[90m"+format+"\033[0m", v...)
}
func White(format string, v ...interface{}) string {
return fmt.Sprintf("\033[37m"+format+"\033[0m", v...)
}
type LogHandler struct {
w io.Writer
level slog.Level
group string
}
func NewHandler(w io.Writer, level slog.Level) *LogHandler {
return &LogHandler{w: w, level: level, group: ""}
}
func (h *LogHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *LogHandler) Handle(_ context.Context, r slog.Record) error {
var file string
var line int
if r.PC != 0 {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
file = f.File
line = f.Line
}
timeStr := r.Time.Format("2006/01/02 15:04:05")
// 检查是否有分组信息(从 attributes 中获取)
group := h.group
r.Attrs(func(a slog.Attr) bool {
if a.Key == "_group" {
group = a.Value.String()
return false // 停止迭代
}
return true
})
levelStr := ""
switch r.Level {
case slog.LevelDebug:
if group != "" {
levelStr = Cyan("[DEBUG/%s]", group)
} else {
levelStr = Cyan("[DEBUG]")
}
case slog.LevelInfo:
if group != "" {
levelStr = Green(fmt.Sprintf("[INFO/%s]", group))
} else {
levelStr = Green("[INFO]")
}
case slog.LevelWarn:
if group != "" {
levelStr = Yellow(fmt.Sprintf("[WARN/%s]", group))
} else {
levelStr = Yellow("[WARN]")
}
case slog.LevelError:
if group != "" {
levelStr = Red(fmt.Sprintf("[ERROR/%s]", group))
} else {
levelStr = Red("[ERROR]")
}
}
var msg string
if file != "" {
msg = fmt.Sprintf("%s %s %s %s",
timeStr,
levelStr,
r.Message,
Gray(fmt.Sprintf("(%s:%d)", file, line)))
} else {
msg = fmt.Sprintf("%s %s %s",
timeStr,
levelStr,
r.Message)
}
r.Attrs(func(a slog.Attr) bool {
if a.Key != "_group" { // 跳过内部使用的 _group 属性
msg += fmt.Sprintf(" %s=%s", Cyan(a.Key), Yellow(fmt.Sprintf("%v", a.Value)))
}
return true
})
msg += "\n"
_, err := h.w.Write([]byte(msg))
return err
}
func (h *LogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h
}
func (h *LogHandler) WithGroup(name string) slog.Handler {
return h
}
// SetupGlobalLogger 设置全局标准库 log 使用 slog handler
func SetupGlobalLogger(level slog.Level) {
handler := NewHandler(os.Stdout, level)
logger := slog.New(handler)
// 设置 slog 默认 logger
slog.SetDefault(logger)
// 设置标准库 log 使用 slog
stdlog.SetOutput(os.Stdout)
stdlog.SetFlags(0) // 清除默认标志
stdlog.SetPrefix("")
// 替换标准库 log 的输出为 slog handler
stdlog.SetOutput(&writerAdapter{handler: handler, level: level})
}
// writerAdapter 将标准库 log 的输出适配到 slog
type writerAdapter struct {
handler slog.Handler
level slog.Level
}
func (w *writerAdapter) Write(p []byte) (n int, err error) {
msg := string(p)
if len(msg) > 0 && msg[len(msg)-1] == '\n' {
msg = msg[:len(msg)-1]
}
var pcs [1]uintptr
runtime.Callers(4, pcs[:]) // skip [Callers, Write, log.Output, log.Printf/etc]
r := slog.NewRecord(time.Now(), w.level, msg, pcs[0])
return len(p), w.handler.Handle(context.Background(), r)
}
// GetWriter 返回一个 io.Writer,可以用于 Gin 等框架
func GetWriter() io.Writer {
handler := slog.Default().Handler()
return &writerAdapter{handler: handler, level: slog.LevelInfo}
}
+3
View File
@@ -4,6 +4,9 @@ import (
_ "github.com/komari-monitor/komari/utils/messageSender/bark"
_ "github.com/komari-monitor/komari/utils/messageSender/email"
_ "github.com/komari-monitor/komari/utils/messageSender/empty"
_ "github.com/komari-monitor/komari/utils/messageSender/javascript"
_ "github.com/komari-monitor/komari/utils/messageSender/serverchan3"
_ "github.com/komari-monitor/komari/utils/messageSender/serverchanturbo"
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
)
+82 -8
View File
@@ -19,6 +19,59 @@ type EmailSender struct {
Addition
}
// loginAuth 是一个更宽松的 SMTP 认证实现,支持更多 SMTP 服务器
// 它不会严格验证服务器主机名,从而兼容微软邮箱、网易邮箱等服务
type loginAuth struct {
username string
password string
host string
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:", "User Name":
return []byte(a.username), nil
case "Password:", "password:":
return []byte(a.password), nil
default:
// 某些服务器可能发送 base64 编码的提示
// 尝试返回用户名或密码
prompt := strings.ToLower(strings.TrimSpace(string(fromServer)))
if strings.Contains(prompt, "user") {
return []byte(a.username), nil
}
return []byte(a.password), nil
}
}
return nil, nil
}
// plainAuthWithoutCheck 是一个不检查主机名的 PlainAuth 实现
// 用于解决某些 SMTP 服务器主机名与配置不匹配的问题
type plainAuthWithoutCheck struct {
identity string
username string
password string
host string
}
func (a *plainAuthWithoutCheck) Start(server *smtp.ServerInfo) (string, []byte, error) {
resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
return "PLAIN", resp, nil
}
func (a *plainAuthWithoutCheck) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
return nil, fmt.Errorf("unexpected server challenge")
}
return nil, nil
}
func (e *EmailSender) GetName() string {
return "email"
}
@@ -41,13 +94,25 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
return fmt.Errorf("email sending is not fully configured")
}
// Prepare auth
auth := smtp.PlainAuth(
"",
e.Addition.Username,
e.Addition.Password,
e.Addition.Host,
)
// 使用更宽松的认证方式,优先尝试 PLAIN,如果失败则尝试 LOGIN
// 这样可以兼容更多的 SMTP 服务器,包括微软邮箱、网易邮箱等
var auth smtp.Auth
if e.Addition.UseLoginAuth {
// 使用 LOGIN 认证(适用于某些旧的或特殊的 SMTP 服务器)
auth = &loginAuth{
username: e.Addition.Username,
password: e.Addition.Password,
host: e.Addition.Host,
}
} else {
// 使用不检查主机名的 PLAIN 认证(适用于大多数现代 SMTP 服务器)
auth = &plainAuthWithoutCheck{
identity: "",
username: e.Addition.Username,
password: e.Addition.Password,
host: e.Addition.Host,
}
}
// Parse sender address (for MAIL FROM and header)
var senderAddr string
@@ -103,13 +168,22 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
return fmt.Errorf("failed to finalize body encoding: %w", err)
}
contentType := "text/plain; charset=UTF-8"
// 检测模板是否包含HTML
trimmedMsg := strings.TrimSpace(message)
if strings.Contains(strings.ToLower(trimmedMsg), "<html") ||
strings.Contains(strings.ToLower(trimmedMsg), "<!doctype") ||
(strings.Contains(trimmedMsg, "<div") && strings.Contains(trimmedMsg, "</div>")) {
contentType = "text/html; charset=UTF-8"
}
// Compose headers
headers := []string{
"To: " + strings.Join(rcptHeaderParts, ", "),
"From: " + senderHeader,
"Subject: " + encodedSubject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Type: " + contentType,
"Content-Transfer-Encoding: quoted-printable",
"Date: " + time.Now().Format(time.RFC1123Z),
fmt.Sprintf("Message-ID: <%d@%s>", time.Now().UnixNano(), e.Addition.Host),
+8 -7
View File
@@ -5,13 +5,14 @@ import (
)
type Addition struct {
Host string `json:"host" required:"true"`
Port int `json:"port" required:"true" default:"587"`
Username string `json:"username"`
Password string `json:"password"`
Sender string `json:"sender"`
Receiver string `json:"receiver"`
UseSSL bool `json:"use_ssl" default:"false"`
Host string `json:"host" required:"true"`
Port int `json:"port" required:"true" default:"587"`
Username string `json:"username"`
Password string `json:"password"`
Sender string `json:"sender"`
Receiver string `json:"receiver"`
UseSSL bool `json:"use_ssl" default:"false"`
UseLoginAuth bool `json:"use_login_auth" default:"false" help:"Use LOGIN authentication method instead of PLAIN. Enable this if you encounter authentication errors with Microsoft (Outlook/Office365), NetEase (163.com), or other email providers"`
}
func init() {
+7
View File
@@ -1,5 +1,7 @@
package factory
import "github.com/komari-monitor/komari/database/models"
type IMessageSender interface {
GetName() string
// 请务必返回 &Configuration{} 的指针
@@ -9,6 +11,11 @@ type IMessageSender interface {
Destroy() error
}
// IEventMessageSender 是可选接口,如果实现则可以接收结构化的事件消息
type IEventMessageSender interface {
SendEvent(event models.EventMessage) error
}
type Configuration interface{}
type MessageSenderConstructor func() IMessageSender
+301
View File
@@ -0,0 +1,301 @@
package javascript
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/dop251/goja"
)
// createFetchFunction 创建一个 fetch API 实现
func (j *JavaScriptSender) createFetchFunction() func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
panic(j.vm.NewTypeError("fetch requires at least 1 argument"))
}
url := call.Argument(0).String()
// 解析选项
options := map[string]interface{}{
"method": "GET",
"headers": make(map[string]string),
"body": "",
}
if len(call.Arguments) > 1 {
optObj := call.Argument(1).ToObject(j.vm)
if optObj != nil {
if method := optObj.Get("method"); method != nil && !goja.IsUndefined(method) {
options["method"] = method.String()
}
if headers := optObj.Get("headers"); headers != nil && !goja.IsUndefined(headers) {
headersObj := headers.ToObject(j.vm)
if headersObj != nil {
headerMap := make(map[string]string)
for _, key := range headersObj.Keys() {
headerMap[key] = headersObj.Get(key).String()
}
options["headers"] = headerMap
}
}
if body := optObj.Get("body"); body != nil && !goja.IsUndefined(body) {
options["body"] = body.String()
}
}
}
// 创建 Promise
promise, resolve, reject := j.vm.NewPromise()
go func() {
defer func() {
if r := recover(); r != nil {
reject(j.vm.ToValue(fmt.Sprintf("fetch panic: %v", r)))
}
}()
// 创建 HTTP 请求
method := options["method"].(string)
var body io.Reader
if options["body"].(string) != "" {
body = strings.NewReader(options["body"].(string))
}
req, err := http.NewRequest(method, url, body)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Failed to create request: %v", err)))
return
}
// 设置请求头
headers := options["headers"].(map[string]string)
for key, value := range headers {
req.Header.Set(key, value)
}
// 发送请求
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Fetch failed: %v", err)))
return
}
defer resp.Body.Close()
// 读取响应体
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Failed to read response: %v", err)))
return
}
// 创建响应对象
responseObj := j.vm.NewObject()
responseObj.Set("status", resp.StatusCode)
responseObj.Set("statusText", resp.Status)
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
// 响应头
headersObj := j.vm.NewObject()
for key, values := range resp.Header {
if len(values) > 0 {
headersObj.Set(key, values[0])
}
}
responseObj.Set("headers", headersObj)
// text() 方法
responseObj.Set("text", func(goja.FunctionCall) goja.Value {
textPromise, textResolve, _ := j.vm.NewPromise()
textResolve(j.vm.ToValue(string(bodyBytes)))
return j.vm.ToValue(textPromise)
})
// json() 方法
responseObj.Set("json", func(goja.FunctionCall) goja.Value {
jsonPromise, jsonResolve, jsonReject := j.vm.NewPromise()
var result interface{}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
jsonReject(j.vm.ToValue(fmt.Sprintf("Failed to parse JSON: %v", err)))
} else {
jsonResolve(j.vm.ToValue(result))
}
return j.vm.ToValue(jsonPromise)
})
resolve(responseObj)
}()
return j.vm.ToValue(promise)
}
}
// createXHRConstructor 创建一个 XMLHttpRequest 构造函数
func (j *JavaScriptSender) createXHRConstructor() func(goja.ConstructorCall) *goja.Object {
return func(call goja.ConstructorCall) *goja.Object {
xhr := call.This
// 内部状态
var method, url string
var headers = make(map[string]string)
var requestBody string
var async = true
// readyState
xhr.Set("readyState", 0)
xhr.Set("status", 0)
xhr.Set("statusText", "")
xhr.Set("responseText", "")
xhr.Set("response", "")
// 事件处理器
xhr.Set("onreadystatechange", goja.Null())
xhr.Set("onload", goja.Null())
xhr.Set("onerror", goja.Null())
// open 方法
xhr.Set("open", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
panic(j.vm.NewTypeError("open requires at least 2 arguments"))
}
method = call.Argument(0).String()
url = call.Argument(1).String()
if len(call.Arguments) > 2 {
async = call.Argument(2).ToBoolean()
}
xhr.Set("readyState", 1)
j.callHandler(xhr, "onreadystatechange")
return goja.Undefined()
})
// setRequestHeader 方法
xhr.Set("setRequestHeader", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
panic(j.vm.NewTypeError("setRequestHeader requires 2 arguments"))
}
key := call.Argument(0).String()
value := call.Argument(1).String()
headers[key] = value
return goja.Undefined()
})
// send 方法
xhr.Set("send", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Argument(0)) && !goja.IsNull(call.Argument(0)) {
requestBody = call.Argument(0).String()
}
sendFunc := func() {
defer func() {
if r := recover(); r != nil {
xhr.Set("readyState", 4)
xhr.Set("status", 0)
xhr.Set("statusText", fmt.Sprintf("Error: %v", r))
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
}
}()
// 创建请求
var body io.Reader
if requestBody != "" {
body = bytes.NewReader([]byte(requestBody))
}
req, err := http.NewRequest(method, url, body)
if err != nil {
xhr.Set("readyState", 4)
xhr.Set("status", 0)
xhr.Set("statusText", err.Error())
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
return
}
// 设置请求头
for key, value := range headers {
req.Header.Set(key, value)
}
// 发送请求
xhr.Set("readyState", 2)
j.callHandler(xhr, "onreadystatechange")
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
xhr.Set("readyState", 4)
xhr.Set("status", 0)
xhr.Set("statusText", err.Error())
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
return
}
defer resp.Body.Close()
// 读取响应
xhr.Set("readyState", 3)
j.callHandler(xhr, "onreadystatechange")
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
xhr.Set("readyState", 4)
xhr.Set("status", resp.StatusCode)
xhr.Set("statusText", err.Error())
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
return
}
// 完成
xhr.Set("readyState", 4)
xhr.Set("status", resp.StatusCode)
xhr.Set("statusText", resp.Status)
xhr.Set("responseText", string(bodyBytes))
xhr.Set("response", string(bodyBytes))
j.callHandler(xhr, "onreadystatechange")
j.callHandler(xhr, "onload")
}
if async {
go sendFunc()
} else {
sendFunc()
}
return goja.Undefined()
})
// getAllResponseHeaders 方法
xhr.Set("getAllResponseHeaders", func(call goja.FunctionCall) goja.Value {
return j.vm.ToValue("")
})
// getResponseHeader 方法
xhr.Set("getResponseHeader", func(call goja.FunctionCall) goja.Value {
return goja.Null()
})
return nil
}
}
// callHandler 调用事件处理器
func (j *JavaScriptSender) callHandler(obj *goja.Object, handlerName string) {
handler := obj.Get(handlerName)
if handler != nil && !goja.IsUndefined(handler) && !goja.IsNull(handler) {
if fn, ok := goja.AssertFunction(handler); ok {
fn(obj)
}
}
}
@@ -0,0 +1,361 @@
package javascript
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/require"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type JavaScriptSender struct {
Addition
vm *goja.Runtime
noopProgram *goja.Program
}
func (j *JavaScriptSender) GetName() string {
return "Javascript"
}
func (j *JavaScriptSender) GetConfiguration() factory.Configuration {
return &j.Addition
}
func (j *JavaScriptSender) Init() error {
if j.Addition.Script == "" {
return errors.New("JavaScript script is empty")
}
// 创建 JavaScript 运行时
j.vm = goja.New()
// 预编译一个 no-op 程序,用于驱动微任务队列
prog, errc := goja.Compile("noop.js", "void 0", false)
if errc == nil {
j.noopProgram = prog
}
// 设置 require 支持
new(require.Registry).Enable(j.vm)
// 注入全局对象和函数
j.setupGlobals()
// 加载用户脚本
_, err := j.vm.RunString(j.Addition.Script)
if err != nil {
return fmt.Errorf("failed to load JavaScript script: %v", err)
}
// 验证 sendMessage 函数是否存在
sendMessage := j.vm.Get("sendMessage")
if sendMessage == nil || goja.IsUndefined(sendMessage) {
return errors.New("sendMessage function not defined in script")
}
// 验证是否可调用
if _, ok := goja.AssertFunction(sendMessage); !ok {
return errors.New("sendMessage is not a function")
}
// sendEvent 函数是可选的,不强制要求存在
return nil
}
func (j *JavaScriptSender) Destroy() error {
if j.vm != nil {
j.vm = nil
}
return nil
}
func (j *JavaScriptSender) SendTextMessage(message, title string) error {
if j.vm == nil {
if err := j.Init(); err != nil {
return err
}
}
// 获取 sendMessage 函数
sendMessageFunc, ok := goja.AssertFunction(j.vm.Get("sendMessage"))
if !ok {
return errors.New("sendMessage is not a callable function")
}
// 调用 sendMessage 函数
resultChan := make(chan error, 1)
timeoutChan := time.After(30 * time.Second)
go func() {
defer func() {
if r := recover(); r != nil {
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
}
}()
result, err := sendMessageFunc(goja.Undefined(), j.vm.ToValue(message), j.vm.ToValue(title))
if err != nil {
resultChan <- fmt.Errorf("JavaScript error: %v", err)
return
}
// 处理 Promise 返回值
if promise, ok := result.Export().(*goja.Promise); ok {
// 等待 Promise 完成
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeoutChan:
resultChan <- errors.New("JavaScript execution timeout")
return
case <-ticker.C:
// 运行微任务以处理 Promise 回调
j.runMicrotasks()
state := promise.State()
if state == goja.PromiseStateFulfilled {
// Promise 成功完成,检查返回值
promiseResult := promise.Result()
if !promiseResult.ToBoolean() {
resultChan <- errors.New("sendMessage returned false")
} else {
resultChan <- nil
}
return
} else if state == goja.PromiseStateRejected {
// Promise 被拒绝
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
return
}
// state == goja.PromiseStatePending, 继续等待
}
}
} else {
// 处理布尔或其他返回值
if result.ToBoolean() {
resultChan <- nil
} else {
resultChan <- errors.New("sendMessage returned false")
}
}
}()
select {
case err := <-resultChan:
return err
case <-timeoutChan:
return errors.New("JavaScript execution timeout after 30 seconds")
}
}
func (j *JavaScriptSender) SendEvent(event models.EventMessage) error {
if j.vm == nil {
if err := j.Init(); err != nil {
return err
}
}
// 检查是否定义了 sendEvent 函数
sendEventValue := j.vm.Get("sendEvent")
if sendEventValue == nil || goja.IsUndefined(sendEventValue) {
// 如果没有定义 sendEvent,则回退到使用 SendTextMessage
return j.fallbackToTextMessage(event)
}
sendEventFunc, ok := goja.AssertFunction(sendEventValue)
if !ok {
// 如果 sendEvent 不是函数,回退到 SendTextMessage
return j.fallbackToTextMessage(event)
}
// 将 EventMessage 转换为 JavaScript 对象
eventJSON, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %v", err)
}
var eventMap map[string]interface{}
if err := json.Unmarshal(eventJSON, &eventMap); err != nil {
return fmt.Errorf("failed to unmarshal event: %v", err)
}
// 调用 sendEvent 函数
resultChan := make(chan error, 1)
timeoutChan := time.After(30 * time.Second)
go func() {
defer func() {
if r := recover(); r != nil {
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
}
}()
result, err := sendEventFunc(goja.Undefined(), j.vm.ToValue(eventMap))
if err != nil {
resultChan <- fmt.Errorf("JavaScript error: %v", err)
return
}
// 处理 Promise 返回值
if promise, ok := result.Export().(*goja.Promise); ok {
// 等待 Promise 完成
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeoutChan:
resultChan <- errors.New("JavaScript execution timeout")
return
case <-ticker.C:
// 运行微任务以处理 Promise 回调
j.runMicrotasks()
state := promise.State()
if state == goja.PromiseStateFulfilled {
// Promise 成功完成,检查返回值
promiseResult := promise.Result()
if !promiseResult.ToBoolean() {
resultChan <- errors.New("sendEvent returned false")
} else {
resultChan <- nil
}
return
} else if state == goja.PromiseStateRejected {
// Promise 被拒绝
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
return
}
// state == goja.PromiseStatePending, 继续等待
}
}
} else {
// 处理布尔或其他返回值
if result.ToBoolean() {
resultChan <- nil
} else {
resultChan <- errors.New("sendEvent returned false")
}
}
}()
select {
case err := <-resultChan:
return err
case <-timeoutChan:
return errors.New("JavaScript execution timeout after 30 seconds")
}
}
// fallbackToTextMessage 当没有定义 sendEvent 时,回退到使用文本消息格式
func (j *JavaScriptSender) fallbackToTextMessage(event models.EventMessage) error {
// 构建简单的文本消息
message := fmt.Sprintf("%s%s%s\nEvent: %s\nMessage: %s\nTime: %s",
event.Emoji, event.Emoji, event.Emoji,
event.Event,
event.Message,
event.Time.Format(time.RFC3339))
// 添加客户端信息
if len(event.Clients) > 0 {
clientNames := make([]string, 0, len(event.Clients))
for _, c := range event.Clients {
name := c.Name
if name == "" {
name = c.UUID
}
clientNames = append(clientNames, name)
}
message = fmt.Sprintf("%s%s%s\nEvent: %s\nClients: %s\nMessage: %s\nTime: %s",
event.Emoji, event.Emoji, event.Emoji,
event.Event,
clientNames,
event.Message,
event.Time.Format(time.RFC3339))
}
return j.SendTextMessage(message, event.Event)
}
// runMicrotasks 安全地推动 goja 的微任务队列(例如 Promise 回调)
func (j *JavaScriptSender) runMicrotasks() {
if j.vm == nil {
return
}
if j.noopProgram != nil {
_, _ = j.vm.RunProgram(j.noopProgram)
return
}
// 兜底: 直接运行一段 no-op 代码
_, _ = j.vm.RunString("void 0")
}
func (j *JavaScriptSender) setupGlobals() {
// 注入 console.log
console := j.vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
var args []interface{}
for _, arg := range call.Arguments {
args = append(args, arg.Export())
}
fmt.Println(args...)
return goja.Undefined()
})
console.Set("error", func(call goja.FunctionCall) goja.Value {
fmt.Print("Error: ")
for i, arg := range call.Arguments {
if i > 0 {
fmt.Print(" ")
}
fmt.Print(arg.Export())
}
fmt.Println()
return goja.Undefined()
})
j.vm.Set("console", console)
// 注入 fetch API
j.vm.Set("fetch", j.createFetchFunction())
// 注入 XMLHttpRequest (xhr)
j.vm.Set("XMLHttpRequest", j.createXHRConstructor())
// 注入 setTimeout
j.vm.Set("setTimeout", func(call goja.FunctionCall) goja.Value {
callback := call.Argument(0)
delay := call.Argument(1).ToInteger()
go func() {
time.Sleep(time.Duration(delay) * time.Millisecond)
if fn, ok := goja.AssertFunction(callback); ok {
fn(goja.Undefined())
}
}()
return goja.Undefined()
})
// 注入 Promise 构造函数
j.vm.RunString(`
if (typeof Promise === 'undefined') {
// Promise polyfill 会由 goja 自动提供
}
`)
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &JavaScriptSender{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*JavaScriptSender)(nil)
+5
View File
@@ -0,0 +1,5 @@
package javascript
type Addition struct {
Script string `json:"script" required:"true" type:"richtext" help:"JavaScript code (ES6 supported) that implements sendMessage(message, title) and optionally sendEvent(event) functions. Both should return a Promise or boolean. Available APIs: fetch(), xhr(), console.log()."`
}
+29 -12
View File
@@ -10,8 +10,8 @@ import (
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
@@ -52,21 +52,21 @@ func Initialize() {
}
})
}()
cfg, _ := config.Get()
NotificationMethod, _ := config.GetAs[string](config.NotificationMethodKey, "none")
if cfg.NotificationMethod == "" || cfg.NotificationMethod == "none" {
if NotificationMethod == "" || NotificationMethod == "none" {
LoadProvider("empty", "{}")
return
}
// 尝试从数据库加载配置
senderConfig, err := database.GetMessageSenderConfigByName(cfg.NotificationMethod)
senderConfig, err := database.GetMessageSenderConfigByName(NotificationMethod)
if err != nil {
// 如果没有找到配置,使用empty provider
LoadProvider("empty", "{}")
return
}
LoadProvider(cfg.NotificationMethod, senderConfig.Addition)
LoadProvider(NotificationMethod, senderConfig.Addition)
}
func SendTextMessage(message string, title string) error {
@@ -74,11 +74,11 @@ func SendTextMessage(message string, title string) error {
return fmt.Errorf("message sender provider is not initialized")
}
var err error
cfg, err := config.Get()
NotificationEnabled, err := config.GetAs[bool](config.NotificationEnabledKey, false)
if err != nil {
return err
}
if !cfg.NotificationEnabled {
if !NotificationEnabled {
return nil
}
for i := 0; i < 3; i++ {
@@ -96,17 +96,34 @@ func SendEvent(event models.EventMessage) error {
return fmt.Errorf("message sender provider is not initialized")
}
var err error
cfg, err := config.Get()
cfg, err := config.GetMany(map[string]any{
config.NotificationEnabledKey: false,
config.NotificationTemplateKey: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
})
if err != nil {
return err
}
if !cfg.NotificationEnabled {
if !cfg[config.NotificationEnabledKey].(bool) {
return nil
}
messageTemplate := cfg.NotificationTemplate
if messageTemplate == "" {
messageTemplate = "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"
// 检查提供者是否实现了 IEventMessageSender 接口
if eventSender, ok := CurrentProvider().(factory.IEventMessageSender); ok {
// 如果实现了,直接调用 SendEvent
for i := 0; i < 3; i++ {
err = eventSender.SendEvent(event)
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" {
auditlog.Log("", "", "Event message sent: "+event.Event, "info")
return nil
}
}
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+event.Event, "error")
return err
}
// 如果没有实现,使用模板格式化为文本消息
messageTemplate := cfg[config.NotificationTemplateKey].(string)
messageTemplate = parseTemplate(messageTemplate, event)
for i := 0; i < 3; i++ {
+21
View File
@@ -0,0 +1,21 @@
package serverchan3
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// Addition 为 Server酱³ 推送通道的配置项
// 所有字段通过管理页面以 JSON 形式进行设置
type Addition struct {
// APIURL 为接口完整地址,例如:https://<uid>.push.ft07.com/send/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://<uid>.push.ft07.com/send/<sendkey>.send;参考:https://sc3.ft07.com/"`
// Tags 为可选标签,使用 | 分割,例如:tag1|tag2|tag3
Tags string `json:"tags" help:"可选标签,使用 | 分割,例如 tag1|tag2|tag3"`
}
// 注册 Server酱³ 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChan3Sender{}
})
}
@@ -0,0 +1,105 @@
package serverchan3
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// ServerChan3Sender 为 Server酱³ 推送实现
type ServerChan3Sender struct {
Addition
}
// GetName 返回推送通道名称
func (s *ServerChan3Sender) GetName() string {
return "Server酱³"
}
// GetConfiguration 返回配置结构体指针
func (s *ServerChan3Sender) GetConfiguration() factory.Configuration {
return &s.Addition
}
// Init 初始化(当前无需处理)
func (s *ServerChan3Sender) Init() error { return nil }
// Destroy 清理(当前无需处理)
func (s *ServerChan3Sender) Destroy() error { return nil }
// SendTextMessage 发送文本消息(携带标题)。
// 简化为固定 JSON POST:内部仅组装 {"title": ..., "desp": ...},不允许用户自定义请求体或内容类型。
func (s *ServerChan3Sender) SendTextMessage(message, title string) error {
apiURL := strings.TrimSpace(s.Addition.APIURL)
if apiURL == "" {
return fmt.Errorf("未配置 Server酱³ 接口地址(api_url)")
}
// 简化校验:若标题与正文均为空则拒绝发送
finalTitle := strings.TrimSpace(title)
finalMessage := strings.TrimSpace(message)
if finalTitle == "" && finalMessage == "" {
return fmt.Errorf("serverchan3: 标题与正文均为空")
}
// 固定 JSON 载荷,包含 title、desp,并支持可选 tags(通过 | 分割)
payload := map[string]string{
"title": finalTitle,
"desp": finalMessage,
}
if tagsStr := strings.TrimSpace(s.Addition.Tags); tagsStr != "" {
parts := strings.Split(tagsStr, "|")
var cleaned []string
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
cleaned = append(cleaned, t)
}
}
if len(cleaned) > 0 {
// Server酱³ 校验提示需要字符串,这里按 | 拼接为字符串
payload["tags"] = strings.Join(cleaned, "|")
}
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("组装 JSON 失败: %v", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 固定设置 Content-Type
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// 读取响应体用于错误信息打印
b, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(b))
if msg == "" {
return fmt.Errorf("接口返回非 2xx 状态码: %d", resp.StatusCode)
}
return fmt.Errorf("接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
}
return nil
}
// 之前的模板、表单解析与手动转义工具不再需要,简化实现
// 确保实现 IMessageSender 接口
var _ factory.IMessageSender = (*ServerChan3Sender)(nil)
@@ -0,0 +1,25 @@
package serverchanturbo
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// Addition 为 Server酱 Turbo 推送通道的配置项
// 仅允许配置接口地址及可选的通道/隐藏IP/openid,固定以 JSON 发送
type Addition struct {
// APIURL 为接口完整地址,例如:https://sctapi.ftqq.com/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://sctapi.ftqq.com/<sendkey>.send;参考:https://sct.ftqq.com/"`
// Channel 为本次推送使用的消息通道,最多两个,多个用 | 隔开,例如:9|66
Channel string `json:"channel" help:"消息通道,可选,多个用 | 隔开,例如 9|66"`
// NoIP 是否隐藏调用 IP,填 1 则隐藏
NoIP string `json:"noip" help:"是否隐藏调用IP,填 1 隐藏;为空则不隐藏"`
// OpenID 消息抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔
OpenID string `json:"openid" help:"抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔"`
}
// 注册 Server酱 Turbo 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChanTurboSender{}
})
}
@@ -0,0 +1,95 @@
package serverchanturbo
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// ServerChanTurboSender 为 Server酱 Turbo 推送实现
type ServerChanTurboSender struct {
Addition
}
// GetName 返回推送通道名称
func (s *ServerChanTurboSender) GetName() string {
return "Server酱Turbo"
}
// GetConfiguration 返回配置结构体指针
func (s *ServerChanTurboSender) GetConfiguration() factory.Configuration {
return &s.Addition
}
// Init 初始化(当前无需处理)
func (s *ServerChanTurboSender) Init() error { return nil }
// Destroy 清理(当前无需处理)
func (s *ServerChanTurboSender) Destroy() error { return nil }
// SendTextMessage 固定以 JSON 发送,仅组装 title 与 desp,支持可选 channel/noip/openid
func (s *ServerChanTurboSender) SendTextMessage(message, title string) error {
apiURL := strings.TrimSpace(s.Addition.APIURL)
if apiURL == "" {
return fmt.Errorf("未配置 Server酱 Turbo 接口地址(api_url)")
}
finalTitle := strings.TrimSpace(title)
finalMessage := strings.TrimSpace(message)
if finalTitle == "" && finalMessage == "" {
return fmt.Errorf("serverchanturbo: 标题与正文均为空")
}
payload := map[string]interface{}{
"title": finalTitle,
"desp": finalMessage,
}
if ch := strings.TrimSpace(s.Addition.Channel); ch != "" {
payload["channel"] = ch
}
if noip := strings.TrimSpace(s.Addition.NoIP); noip == "1" {
payload["noip"] = "1"
}
if openid := strings.TrimSpace(s.Addition.OpenID); openid != "" {
payload["openid"] = openid
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("serverchanturbo: 组装 JSON 失败: %v", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("serverchanturbo: 创建请求失败: %v", err)
}
// 使用 JSON 传递参数,需要设置 Content-Type
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("serverchanturbo: 发送请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(b))
if msg == "" {
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d", resp.StatusCode)
}
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
}
return nil
}
// 确保实现 IMessageSender 接口
var _ factory.IMessageSender = (*ServerChanTurboSender)(nil)
+52 -60
View File
@@ -6,87 +6,79 @@ import (
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
func CheckExpireScheduledWork() {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location()) // UTC 9AM = CST 17PM
if now.After(next) {
next = next.Add(24 * time.Hour)
}
duration := next.Sub(now)
time.Sleep(duration)
CheckExpire()
}
cfg, err := config.Get()
if err != nil {
time.Sleep(time.Second)
continue
func CheckExpire() {
cfg, err := config.GetMany(map[string]any{
config.ExpireNotificationEnabledKey: false,
config.ExpireNotificationLeadDaysKey: 7,
})
if err != nil {
return
}
clients_all, err := clients.GetAllClientBasicInfo()
if err != nil {
return
}
checkTime := time.Now()
// 过期提醒检查(仅当启用过期通知时)
if cfg[config.ExpireNotificationEnabledKey].(bool) {
notificationLeadDays := int(cfg[config.ExpireNotificationLeadDaysKey].(float64)) // Json unmarshal 会将数字解析为 float64
type clientToExpireInfo struct {
Name string
DaysLeft int
}
clients_all, err := clients.GetAllClientBasicInfo()
if err != nil {
time.Sleep(time.Second)
continue
}
var clientLeadToExpire []clientToExpireInfo
checkTime := time.Now()
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
// 过期提醒检查(仅当启用过期通知时)
if cfg.ExpireNotificationEnabled {
notificationLeadDays := cfg.ExpireNotificationLeadDays
type clientToExpireInfo struct {
Name string
DaysLeft int
if clientExpireTime.Before(checkTime) {
continue
}
var clientLeadToExpire []clientToExpireInfo
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
if clientExpireTime.Before(checkTime) {
continue
}
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
if len(clientLeadToExpire) > 0 {
message := ""
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("• %s (%dd)\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Expire,
Time: time.Now(),
Message: message,
Emoji: "⏳",
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
// 等待1秒,防止多次触发
time.Sleep(time.Second)
for _, client := range clients_all {
renewal.CheckAndAutoRenewal(client)
if len(clientLeadToExpire) > 0 {
message := ""
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("• %s (%dd)\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Expire,
Time: time.Now(),
Message: message,
Emoji: "⏳",
})
}
}
for _, client := range clients_all {
renewal.CheckAndAutoRenewal(client)
}
}
+42 -31
View File
@@ -1,6 +1,7 @@
package notifier
import (
"fmt"
"log"
"reflect"
"sync"
@@ -11,21 +12,18 @@ import (
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/utils/messageSender"
)
// LoadNotificationService 管理定时器和任务
type LoadNotificationService struct {
mu sync.Mutex
tickers map[int]*time.Ticker
tasks map[int][]models.LoadNotification
stopChan chan struct{}
mu sync.Mutex
tasks map[int][]models.LoadNotification
}
var LoadNotificationManager = &LoadNotificationService{
tickers: make(map[int]*time.Ticker),
tasks: make(map[int][]models.LoadNotification),
stopChan: make(chan struct{}),
tasks: make(map[int][]models.LoadNotification),
}
// Reload 重载时间表
@@ -33,11 +31,7 @@ func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotifica
m.mu.Lock()
defer m.mu.Unlock()
// 停止所有现有定时器
for _, ticker := range m.tickers {
ticker.Stop()
}
m.tickers = make(map[int]*time.Ticker)
corn.RemovePrefix("load-notification:")
m.tasks = make(map[int][]models.LoadNotification)
// 按Interval分组任务
@@ -48,22 +42,16 @@ func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotifica
// 为每个唯一的Interval创建定时器
for interval, tasks := range taskGroups {
ticker := time.NewTicker(time.Duration(interval) * time.Minute)
m.tickers[interval] = ticker
interval := interval
tasks := append([]models.LoadNotification(nil), tasks...)
m.tasks[interval] = tasks
go func(ticker *time.Ticker, tasks []models.LoadNotification) {
for {
select {
case <-ticker.C:
for _, task := range tasks {
go executeLoadNotificationTask(task)
}
case <-m.stopChan:
return
}
if err := corn.AddFunc(fmt.Sprintf("load-notification:%d", interval), corn.Every(time.Duration(interval)*time.Minute), func() {
for _, task := range tasks {
go executeLoadNotificationTask(task)
}
}(ticker, tasks)
}); err != nil {
return err
}
}
return nil
@@ -140,22 +128,31 @@ func checkMetricThreshold(records []models.Record, task models.LoadNotification)
// getMetricValue 根据指标名称获取记录中的对应值
func getMetricValue(record models.Record, metric string) float32 {
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
switch metric {
case "cpu":
return record.Cpu
case "gpu":
return record.Gpu
case "net_in", "netin":
return bytesPerSecondToMbps(record.NetIn)
case "net_out", "netout":
return bytesPerSecondToMbps(record.NetOut)
case "ram":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.RamTotal > 0 {
return float32(record.Ram) / float32(client.MemTotal) * 100
}
return 0
case "swap":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.SwapTotal > 0 {
return float32(record.Swap) / float32(client.SwapTotal) * 100
}
@@ -165,6 +162,11 @@ func getMetricValue(record models.Record, metric string) float32 {
case "temp":
return record.Temp
case "disk":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.DiskTotal > 0 {
return float32(record.Disk) / float32(client.DiskTotal) * 100
}
@@ -187,6 +189,15 @@ func getMetricValue(record models.Record, metric string) float32 {
}
}
func bytesPerSecondToMbps(bytesPerSecond int64) float32 {
if bytesPerSecond <= 0 {
return 0
}
// 采用十进制 Mbps:1 Mbps = 1,000,000 bit/s
return float32(float64(bytesPerSecond) * 8 / 1_000_000)
}
// sendLoadNotification 发送负载通知
func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
ex_clients := []models.Client{}
+3 -3
View File
@@ -7,10 +7,10 @@ import (
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
@@ -32,8 +32,8 @@ var clientStates sync.Map
// getNotificationConfig 获取指定客户端的通知配置。
// 返回配置对象和一个布尔值,指示全局和该客户端是否启用通知。
func getNotificationConfig(clientID string) (*models.OfflineNotification, bool) {
conf, err := config.Get()
if err != nil || !conf.NotificationEnabled {
conf, err := config.GetAs[bool](config.NotificationEnabledKey, false)
if err != nil || !conf {
return nil, false
}
+13 -8
View File
@@ -2,15 +2,16 @@ package notifier
import (
"fmt"
"log/slog"
"math"
"strings"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/ws"
agent_runtime "github.com/komari-monitor/komari/web/agent"
cache "github.com/patrickmn/go-cache"
)
@@ -22,22 +23,21 @@ var trafficCache = cache.New(30*24*time.Hour, time.Hour) // 30天缓存,1小
// 由外部协程每分钟调用一次
func CheckTraffic() {
// 获取最新上报与客户端配置
reports := ws.GetLatestReport()
reports := agent_runtime.GetLatestReport()
if len(reports) == 0 {
return
}
cfg, err := config.Get()
cfg, err := config.GetAs[float64](config.TrafficLimitPercentageKey, 80.0)
if err != nil {
return
slog.Error("failed to get traffic limit percentage", "error", err)
}
if cfg.TrafficLimitPercentage <= 0 {
if cfg <= 0 {
return
}
// 起始阈值:例如 80%,非5的倍数则从上取整到最近的5的倍数,例如 83->85
startThreshold := cfg.TrafficLimitPercentage
startThreshold := cfg
if startThreshold < 0 {
startThreshold = 0
}
@@ -85,6 +85,11 @@ func CheckTraffic() {
last, _ := trafficCache.Get(key)
lastStep, _ := last.(int)
// 修复:当检测到当前进度小于历史记录时,说明流量已重置,将基准归零
if curStep < lastStep {
lastStep = 0
}
if curStep > lastStep { // 只在进入新步进时提醒一次
trafficCache.SetDefault(key, curStep)
+221
View File
@@ -0,0 +1,221 @@
package notifier
import (
"fmt"
"log"
"strings"
"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/pkg/corn"
"github.com/komari-monitor/komari/utils/messageSender"
"gorm.io/gorm"
)
// InitTrafficReportSchedule 注册三个定时任务:日报、周报、月报
func InitTrafficReportSchedule() {
// 日报:每天凌晨 0 点
if err := corn.AddFunc("traffic-report-daily", "0 0 0 * * *", func() {
sendTrafficReport(true, false, false)
}); err != nil {
log.Println("Failed to register daily traffic report job:", err)
}
// 周报:每周一凌晨 0 点 (dow=1)
if err := corn.AddFunc("traffic-report-weekly", "0 0 0 * * 1", func() {
sendTrafficReport(false, true, false)
}); err != nil {
log.Println("Failed to register weekly traffic report job:", err)
}
// 月报:每月 1 日凌晨 0 点
if err := corn.AddFunc("traffic-report-monthly", "0 0 0 1 * *", func() {
sendTrafficReport(false, false, true)
}); err != nil {
log.Println("Failed to register monthly traffic report job:", err)
}
log.Println("Traffic report schedules registered: daily, weekly, monthly")
}
// sendTrafficReport 汇聚所有启用了指定报告类型的服务器流量,合并成一条通知发送
func sendTrafficReport(daily, weekly, monthly bool) {
// 检查全局通知开关
enabled, err := config.GetAs[bool](config.NotificationEnabledKey, false)
if err != nil || !enabled {
return
}
db := dbcore.GetDBInstance()
now := time.Now()
// 计算时间范围
var start, end time.Time
var eventType, label, suffix string
switch {
case daily:
yesterday := now.AddDate(0, 0, -1)
start = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location())
end = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 59, 59, 0, yesterday.Location())
eventType = messageevent.DReport
label = "daily"
suffix = "昨日流量"
case weekly:
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
lastMonday := now.AddDate(0, 0, -(weekday-1)-7)
lastSunday := lastMonday.AddDate(0, 0, 6)
start = time.Date(lastMonday.Year(), lastMonday.Month(), lastMonday.Day(), 0, 0, 0, 0, lastMonday.Location())
end = time.Date(lastSunday.Year(), lastSunday.Month(), lastSunday.Day(), 23, 59, 59, 0, lastSunday.Location())
eventType = messageevent.WReport
label = "weekly"
suffix = "上周流量"
case monthly:
firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
lastDayOfLastMonth := firstOfThisMonth.Add(-time.Second)
start = firstOfLastMonth
end = lastDayOfLastMonth
eventType = messageevent.MReport
label = "monthly"
suffix = "上个月流量"
default:
return
}
// 查询所有启用该类型报告的服务器配置
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)
}
if err := query.Find(&notifications).Error; err != nil {
log.Printf("Failed to query traffic report notifications (%s): %v", label, err)
return
}
if len(notifications) == 0 {
return
}
// 获取客户端信息
clientUUIDs := make([]string, 0, len(notifications))
for _, n := range notifications {
clientUUIDs = append(clientUUIDs, n.Client)
}
var clientList []models.Client
if err := db.Where("uuid IN ?", clientUUIDs).Find(&clientList).Error; err != nil {
log.Printf("Failed to query clients for traffic report (%s): %v", label, err)
return
}
clientMap := make(map[string]models.Client, len(clientList))
for _, c := range clientList {
clientMap[c.UUID] = c
}
// 为每个服务器统计流量并拼接消息
var lines []string
eventClients := make([]models.Client, 0, len(notifications))
for _, n := range notifications {
c, ok := clientMap[n.Client]
if !ok {
continue
}
used, err := getClientTrafficInRange(n.Client, c.TrafficLimitType, start, end)
if err != nil {
log.Printf("Failed to compute traffic for client %s (%s): %v", n.Client, label, err)
continue
}
lines = append(lines, fmt.Sprintf("%s%s:%s", c.Name, suffix, humanBytes(used)))
eventClients = append(eventClients, c)
}
if len(lines) == 0 {
return
}
message := strings.Join(lines, "\n")
var emoji string
switch {
case daily:
emoji = "📊"
case weekly:
emoji = "📈"
case monthly:
emoji = "📅"
}
if err := messageSender.SendEvent(models.EventMessage{
Event: eventType,
Clients: eventClients,
Time: now,
Emoji: emoji,
Message: message,
}); err != nil {
log.Printf("Failed to send %s traffic report: %v", label, err)
}
}
// getClientTrafficInRange 查询某客户端在指定时间段内的流量增量
// 通过累加持久化的精确流量增量字段计算用量
func getClientTrafficInRange(clientUUID string, trafficType string, start, end time.Time) (int64, error) {
return getClientTrafficInRangeWithDB(dbcore.GetDBInstance(), clientUUID, trafficType, start, end)
}
func getClientTrafficInRangeWithDB(db *gorm.DB, clientUUID string, trafficType string, start, end time.Time) (int64, error) {
type trafficDeltaRecord struct {
Time models.LocalTime `gorm:"column:time"`
TrafficUp int64 `gorm:"column:traffic_up"`
TrafficDown int64 `gorm:"column:traffic_down"`
}
var recentRecords []trafficDeltaRecord
if err := db.Table("records").
Select("time, traffic_up, traffic_down").
Where("client = ? AND time >= ? AND time <= ?", clientUUID, start, end).
Find(&recentRecords).Error; err != nil {
return 0, err
}
var longTermRecords []trafficDeltaRecord
if err := db.Table("records_long_term").
Select("time, traffic_up, traffic_down").
Where("client = ? AND time >= ? AND time <= ?", clientUUID, start, end).
Find(&longTermRecords).Error; err != nil {
return 0, err
}
var totalUp int64
var totalDown int64
longTermSlots := make(map[time.Time]struct{}, len(longTermRecords))
for _, record := range longTermRecords {
totalUp += record.TrafficUp
totalDown += record.TrafficDown
longTermSlots[record.Time.ToTime()] = struct{}{}
}
for _, record := range recentRecords {
// records_long_term stores one aggregated row per 15-minute slot.
// Skip raw rows only when that exact slot is already present there.
slot := record.Time.ToTime().Truncate(15 * time.Minute)
if _, exists := longTermSlots[slot]; exists {
continue
}
totalUp += record.TrafficUp
totalDown += record.TrafficDown
}
return computeUsedByType(strings.ToLower(trafficType), totalUp, totalDown), nil
}
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more