Compare commits

..
98 Commits
Author SHA1 Message Date
Akizon 433bc52788 Merge pull request #42 from komari-monitor/dev
feat: 添加2FA支持
2025-06-11 19:35:59 +08:00
Akizon77 6aeb444b2b feat: 在更新用户信息时添加用户存在性检查 2025-06-07 17:06:24 +08:00
Akizon77 bbf631714d feat: 添加双因素认证支持 2025-06-07 16:30:16 +08:00
Akizon c204094173 Update todo.md 2025-06-07 15:38:52 +08:00
Akizon 417cec5d44 Update README.md 2025-06-07 15:34:03 +08:00
Akizon 5401b4c10e Update README.md 2025-06-07 15:32:54 +08:00
Akizon f1cfbd9391 Merge pull request #41 from komari-monitor/dev
feat: 添加日志功能
2025-06-06 18:02:32 +08:00
Akizon77 ae6f9a7840 refactor: 移除冗余的版本文件 2025-06-06 18:00:56 +08:00
Akizon77 a75ac3e97d feat: session管理更加细致,识别UA、IP 2025-06-06 17:34:53 +08:00
Akizon77 00fa79e31c fix: 登陆后的重定向从/admin更改为/manage 2025-06-06 17:14:54 +08:00
Akizon77 392b05be94 feat: 更新日志获取接口,返回日志总数 2025-06-06 16:54:16 +08:00
Akizon77 26e120cc89 fix: 查询日志传入小于等于0的页数报500 2025-06-06 16:29:40 +08:00
Akizon77 413dd780f2 feat: 优化设置更新日志,忽略特定字段 2025-06-06 16:21:30 +08:00
Akizon77 3d26743492 feat: 添加服务器启动和关闭的信号处理,记录日志信息 2025-06-06 16:17:12 +08:00
Akizon77 0a6927805e feat: 添加分页功能,支持限制日志数量和选择页码 2025-06-06 16:06:29 +08:00
Akizon77 d16889c103 feat: 更新日志记录,添加客户端UUID信息 2025-06-06 16:01:19 +08:00
Akizon77 7894990d91 feat: 添加终端连接建立和断开的时间记录 2025-06-06 15:55:58 +08:00
Akizon77 fe3afceca9 feat: 终端添加日志 2025-06-06 15:54:42 +08:00
Akizon77 014ee67d69 feat:记录用户通过密码和OAuth登录的操作 2025-06-06 15:29:32 +08:00
Akizon77 ec4dee765a feat: 支持获取和删除旧日志 2025-06-06 15:05:39 +08:00
Akizon77 00d8936455 feat: 添加日志记录功能,记录用户操作及相关信息 2025-06-06 15:00:35 +08:00
Akizon77 4b97ae92da feat: 添加Telegram消息发送功能及GeoIP查询接口 2025-06-06 14:41:44 +08:00
Akizon77 a33885255c fix: 统一未授权响应处理,使用统一的错误响应函数 2025-06-05 19:01:15 +08:00
Akizon77 fd8a4c4895 feat: 统一API响应处理,简化错误和成功响应逻辑 2025-06-05 18:59:17 +08:00
Akizon77 039fd6b8d9 feat: 统一(大部分)API响应格式 2025-06-05 18:48:50 +08:00
Akizon77 09c346180e feat: 允许禁止密码登录
refactor: 大致上应该错误都返回message
2025-06-05 17:54:40 +08:00
Akizon77 179606ea6e fix: 自定义head不生效的bug 2025-06-04 23:00:28 +08:00
Akizon 2118648a58 Merge pull request #40 from komari-monitor/dev
feat: 添加上传和删除 favicon 的功能
2025-06-04 22:21:20 +08:00
Akizon77 db26327140 feat: 添加上传和删除 favicon 的功能 2025-06-04 22:12:39 +08:00
Akizon 5132b60f18 Merge pull request #39 from komari-monitor/dev
feat: 支持更新用户信息 & 手动更新geoip数据库
2025-06-04 20:48:44 +08:00
Akizon77 43cfbca62f feat: 更新用户信息 & 手动更新geoip数据库 2025-06-04 20:44:16 +08:00
Akizon77 1cc6f22a5a feat: 增加动态 CORS 支持和配置事件发布机制 2025-06-04 20:08:56 +08:00
Akizon dfc242dc0e Merge pull request #38 from komari-monitor/dev
fix: 修复 CORS
2025-06-04 19:21:08 +08:00
Akizon77 f1d17d9bcd feat: 清理过期任务结果 2025-06-04 19:17:41 +08:00
Akizon77 403879e705 fix: 修复配置中的 CORS 列名错误 2025-06-04 19:15:21 +08:00
Akizon77 cbc91cfb0c fix: 修复 CORS 2025-06-04 18:19:22 +08:00
Akizon b709ac9e98 Merge pull request #37 from komari-monitor/dev
feat: 添加批量执行任务
2025-06-04 17:46:27 +08:00
Akizon77 6d29b165ca feat: 增加任务结果的详细信息 2025-06-04 17:46:06 +08:00
Akizon77 44b1106dec feat: task 存储数组 2025-06-04 17:31:39 +08:00
Akizon77 2bb96b7272 Merge branch 'dev' of https://github.com/komari-monitor/komari into dev 2025-06-04 16:19:27 +08:00
Akizon77 2cd1159f94 feat: 添加批量执行任务 2025-06-04 16:19:22 +08:00
Akizon 5278803fb1 Merge pull request #36 from komari-monitor/dev
移除mmdb下载地址代理,增加版本号信息
2025-06-04 13:26:51 +08:00
Akizon 662a3033f7 Merge branch 'main' into dev 2025-06-04 13:26:43 +08:00
Akizon77 4c55e52374 feat: 添加版本信息API接口,支持获取当前版本和哈希 2025-06-03 23:25:50 +08:00
Akizon77 864b91fad1 ci/cd: 版本信息构建 2025-06-03 23:21:57 +08:00
Akizon77 8ed2e5a774 cd: 使用完整前端 2025-06-03 23:07:26 +08:00
Akizon77 907419ec73 cd: 工作流处理文件已存在 2025-05-30 16:53:12 +08:00
Akizon77 dc05937331 fix: 移除geoip下载地址代理 2025-05-30 16:44:21 +08:00
Akizon77 3169ec687b feat: 添加开发环境工作流,支持多平台构建和生产部署 2025-05-30 15:47:17 +08:00
Akizon 9160c1eef7 Merge pull request #35 from komari-monitor/dev
refactor: Client优化模型
2025-05-29 18:08:36 +08:00
Akizon77 7b95e270b5 fix: 修复EditClient函数中UUID的处理逻辑 2025-05-29 18:07:03 +08:00
Akizon77 95cf1d78d0 feat: 添加SSO相关信息返回 2025-05-29 17:51:04 +08:00
Akizon77 fefdcee60d refactor: 更新Client相关逻辑,迁移旧版ClientInfo数据并优化模型 2025-05-29 17:29:02 +08:00
Akizon 1f7bdf60c2 Merge pull request #34 from komari-monitor/dev
fix: 数据库charset=utf8mb4
2025-05-27 23:00:16 +08:00
Akizon77 554caf7a37 fix: 数据库charset=utf8mb4 2025-05-27 22:12:52 +08:00
Akizon 61277a8fc8 Merge pull request #33 from komari-monitor/dev
refactor: 默认服务端配置的创建
2025-05-27 17:19:29 +08:00
Akizon b178ceb62f Update docker-publish.yml 2025-05-27 17:18:28 +08:00
Akizon77 793852f7dc refactor: 优化Get函数,移除重复的默认配置创建逻辑 2025-05-27 17:17:35 +08:00
Akizon77 276feae7f6 feat: 在Update函数中添加默认配置创建逻辑 2025-05-27 17:13:43 +08:00
Akizon 3f6073cab4 Merge pull request #32 from komari-monitor/dev
fix: 修复Session表的索引冲突并优化数据库清理逻辑
2025-05-27 16:23:25 +08:00
Akizon77 a9f2c7658d fix: 修复Session表的索引冲突并优化数据库清理逻辑 2025-05-27 16:21:56 +08:00
Akizon da6eaddf3f Merge pull request #31 from komari-monitor/dev
refactor: 更新UploadBasicInfo函数以使用SaveClientInfo,移除UpdateOrInsertBasicI…
2025-05-27 15:50:07 +08:00
Akizon77 e0175e3b6c refactor: 更新UploadBasicInfo函数以使用SaveClientInfo,移除UpdateOrInsertBasicInfo的依赖 2025-05-27 15:46:43 +08:00
Akizon bd2d9117d9 Merge pull request #30 from komari-monitor/dev
feat: 添加mysql支持,新增部分公开字段
2025-05-26 20:26:29 +08:00
Akizon77 cde3a55451 feat: 添加billing_cycle 2025-05-26 19:32:17 +08:00
Akizon77 d3ad29439a feat: 添加虚拟化 2025-05-26 19:29:37 +08:00
Akizon77 fb80e48010 feat: 添加数据库配置,支持MySQL,修改uuid类型为varchar(36),移除默认时间字段 2025-05-25 18:50:33 +08:00
Akizon77 513e59dbff feat: 添加终端连接功能和会话管理 2025-05-16 22:04:44 +08:00
Akizon 7d79825345 Update README.md 2025-05-16 18:24:58 +08:00
Akizon77 735a08ad75 fix: 添加public_remark字段 2025-05-15 21:07:59 +08:00
Akizon77 874b272f55 fix: 确保发给前端的数据中有cpu usage 2025-05-15 20:59:25 +08:00
Akizon 4d86cb3632 Merge pull request #29 from komari-monitor/dev
fix: #28 修改Settings时如果不包含全部信息会导致丢失数据
fix: 修改Config结构体中的字段名称以提高一致性
feat: 添加中间件以禁用API请求的缓存
fix: 统一Record结构体字段名称以提高一致性
fix: 修复AddClient和EditClient函数中的JSON绑定和字段更新逻辑
feat: 添加获取客户端最近记录的API接口
2025-05-15 20:54:02 +08:00
Akizon77 5316a4d3b2 feat: 添加获取客户端最近记录的API接口 2025-05-15 20:49:12 +08:00
Akizon77 5d386a18e4 fix: 修复AddClient和EditClient函数中的JSON绑定和字段更新逻辑 2025-05-15 20:43:13 +08:00
Akizon77 6e65706a23 fix: 统一Record结构体字段名称以提高一致性 2025-05-15 20:33:11 +08:00
Akizon 55e48ee8f1 Update README.md 2025-05-13 00:35:33 +08:00
Akizon caf330265a Update LICENSE 2025-05-12 20:18:02 +08:00
Akizon 9e86118683 Update LICENSE 2025-05-12 20:15:44 +08:00
Akizon e5d7c2f89c Update README.md 2025-05-12 20:12:08 +08:00
Akizon77 803251fbc1 wip: 添加价格、过期时间 2025-05-12 19:33:15 +08:00
Akizon77 b6b75c9aa2 feat: 添加中间件以禁用API请求的缓存 2025-05-12 17:06:14 +08:00
Akizon77 07ff3c1cfe fix: 修改Config结构体中的字段名称以提高一致性 2025-05-12 16:57:47 +08:00
Akizon77 544aada3b9 fix: #28 修改Settings时如果不包含全部信息会导致丢失数据 2025-05-12 16:41:34 +08:00
Akizon 64683bc50a Merge pull request #27 from komari-monitor/dev
feat: #25 添加批量修改weight权重的接口
feat: #24 添加logout登出
feat: 添加OAuth功能及相关API接口
2025-05-12 16:23:11 +08:00
Akizon77 62b0dff1f4 feat: #25 添加批量修改weight权重的接口
fix: #24 logout 接口
2025-05-12 16:21:31 +08:00
Akizon77 5accc9d258 feat: #24 添加logout登出 2025-05-12 16:12:20 +08:00
Akizon77 8535b4ba7a fix: # 26add client 可以指定名称
fix: 修改日志记录方式,避免在读取index.html失败时导致程序崩溃
2025-05-12 16:09:47 +08:00
Akizon77 cc3cda12cf 改回来了www 2025-05-08 23:02:19 +08:00
Akizon77 4ecf1fccd7 npm -> pnpm 2025-05-08 23:00:54 +08:00
Akizon77 74b9b8f9c8 fix: 修复OAuth回调中cookie设置逻辑,确保状态验证后正确清除cookie 2025-05-08 20:41:42 +08:00
Akizon77 9b2a33824a fix: 修复OAuth未启用时的HTTP状态码返回 2025-05-08 20:28:53 +08:00
Akizon77 2c3b5f7a47 feat: 添加OAuth功能及相关API接口
修改API路径
2025-05-08 20:13:31 +08:00
Akizon d81ec0eee6 非常抱歉!!漏了一个 '\' 2025-05-08 16:56:57 +08:00
Akizon 0ccda568ed Update README.md 2025-05-08 16:52:08 +08:00
Akizon 2cf38a729a Merge pull request #23 from komari-monitor/dev
feat: 添加获取公共设置的API接口
2025-05-08 16:21:16 +08:00
Akizon77 77aa490696 feat: 添加获取公共设置的API接口 2025-05-08 16:20:44 +08:00
Akizon77 2e524fda42 fix: 修复配置保存时的时间戳和ID设置逻辑 2025-05-08 16:07:26 +08:00
Akizon77 9a6b2b8a35 feat: #19 1分钟的历史记录 2025-05-07 23:19:08 +08:00
62 changed files with 2832 additions and 388 deletions

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
name: Development Environment
on:
push:
branches:
- dev
workflow_dispatch:
jobs:
build-binaries:
runs-on: ubuntu-latest
steps:
- name: Checkout code
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
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Build application
env:
GOOS: linux
GOARCH: amd64
run: |
VERSION_SHA="${{ github.sha }}"
go build -ldflags "-X github.com/komari-monitor/komari/utils.CurrentVersion=dev -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_SHA}" -o komari .
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari
path: komari
deploy-to-production:
needs: build-binaries
runs-on: ubuntu-latest
env:
PRODUCTION_SERVER: ${{ vars.PRODUCTION_SERVER }}
if: github.ref == 'refs/heads/dev'
steps:
- name: Download binary
uses: actions/download-artifact@v4
with:
name: komari
- name: Setup SSH Key
uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.PRODUCTION_SSH_KEY }}
- name: Add host to known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts
- name: Deploy to production
run: |
chmod +x komari
ssh root@${{ env.PRODUCTION_SERVER }} 'mkdir -p /opt/komari/'
ssh root@${{ env.PRODUCTION_SERVER }} 'systemctl stop komari.service || true'
ssh root@${{ env.PRODUCTION_SERVER }} 'rm -f /opt/komari/komari.old'
ssh root@${{ env.PRODUCTION_SERVER }} 'mv /opt/komari/komari /opt/komari/komari.old || true'
scp komari root@${{ env.PRODUCTION_SERVER }}:/opt/komari/komari
ssh root@${{ env.PRODUCTION_SERVER }} 'systemctl start komari.service'
+3 -1
View File
@@ -3,7 +3,6 @@ name: Docker Build and Publish
on:
push:
branches:
- dev
- main
workflow_dispatch:
@@ -45,3 +44,6 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
VERSION_HASH=${{ github.sha }}
+7 -2
View File
@@ -55,7 +55,9 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME=${BINARY_NAME}.exe
fi
go build -trimpath -ldflags="-s -w" -o $BINARY_NAME
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
- name: Upload binary to release
env:
@@ -97,4 +99,7 @@ jobs:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.event.release.tag_name }}
VERSION_HASH=${{ github.sha }}
+15 -2
View File
@@ -16,8 +16,10 @@ COPY . .
COPY --from=frontend /web/dist ./public/dist
ENV CGO_ENABLED=1
ARG VERSION=unknown
ARG VERSION_HASH=unknown
# RUN CC=musl-gcc go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static" -o komari .
RUN go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static" -o komari .
RUN go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o komari .
# FROM scratch
FROM alpine:3.21
@@ -26,6 +28,17 @@ WORKDIR /app
COPY --from=builder /app/komari .
ENV GIN_MODE=release
# 数据库配置环境变量(可以在运行时覆盖)
ENV KOMARI_DB_TYPE=sqlite
ENV KOMARI_DB_FILE=/app/data/komari.db
ENV KOMARI_DB_HOST=localhost
ENV KOMARI_DB_PORT=3306
ENV KOMARI_DB_USER=root
ENV KOMARI_DB_PASS=
ENV KOMARI_DB_NAME=komari
ENV KOMARI_LISTEN=0.0.0.0:25774
EXPOSE 25774
CMD ["/app/komari","server", "-l", "0.0.0.0:25774","-d", "/app/data/komari.db"]
# 使用环境变量启动服务
CMD ["/app/komari","server"]
+131 -1
View File
@@ -1 +1,131 @@
# komari
# Komari
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
[文档](https://komari-monitor.github.io/komari-document/)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
## 快速开始
### 依赖
- Docker(快速部署)
- 或者 Go 1.18+ 和 Node.js 20+(手工构建)
### Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
```
2. 运行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看默认账号和密码:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
### 手工构建
1. 构建前端静态文件:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 构建后端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
```bash
go build -o komari
```
4. 运行:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
## 前端开发指南
这个坑晚点再填吧 ヽ( ̄ω ̄( ̄ω ̄〃)ゝ
## 客户端 Agent 开发指南
这个坑晚点再填吧 (o゜▽゜)o☆
## 贡献
欢迎提交 Issue 或 Pull Request!
## 鸣谢
- 感谢 [Alice Networks LTD](https://app.alice.ws/) 提供的免费服务器
## 引用
- [gorm.io](https://gorm.io/)
- [spf13/cobra](https://github.com/spf13/cobra)
- [oschwald/maxminddb-golang](https://github.com/oschwald/maxminddb-golang)
- [gorilla/websocket](https://github.com/gorilla/websocket)
- [google/uuid](https://github.com/google/uuid)
- [gin-gonic/gin](https://github.com/gin-gonic/gin)
- [UserExistsError/conpty](https://github.com/UserExistsError/conpty)
- [creack/pty](https://github.com/creack/pty)
- [rhysd/go-github-selfupdate](https://github.com/rhysd/go-github-selfupdate)
- [shirou/gopsutil](https://github.com/shirou/gopsutil)
## 许可证
[MIT License](LICENSE)
## 截图
![PixPin_2025-06-07_15-28-30](https://github.com/user-attachments/assets/edce5694-c6c8-4647-bc11-8b27105fd55c)
![PixPin_2025-06-07_15-28-49](https://github.com/user-attachments/assets/23b58032-211a-4b59-b444-c8267606a0bb)
![PixPin_2025-06-07_15-29-05](https://github.com/user-attachments/assets/f4325d4d-fa69-41c6-9251-19f530476e65)
![PixPin_2025-06-07_15-30-05](https://github.com/user-attachments/assets/69be38f6-6681-4afb-9a04-965d39b7fcc2)
![PixPin_2025-06-07_15-30-46](https://github.com/user-attachments/assets/01d84dd1-3c81-4424-a041-7dbc2ae5822e)
## 碎碎念
### 我为什么做Komari?
起因: [求推荐系统监控软件](https://www.nodeseek.com/post-133745-1)
> @古xx斯
> 哪吒?
> > 数据至少保存15天
> @sxxu
> 怕不是做梦的预算
> @Vxxn
> prometheus加grafana
> > 部署太麻烦
没有合适的,最近刚好时间稍微多了一些,就搓了 komari.
+7 -3
View File
@@ -13,18 +13,22 @@ func AdminAuthMiddleware() gin.HandlerFunc {
session, err := c.Cookie("session_token")
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Unauthorized"})
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
// Komari is a single user system
_, err = accounts.GetSession(session)
uuid, err := accounts.GetSession(session)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Unauthorized."})
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()
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"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/utils"
)
var (
Records map[string][]common.Report = make(map[string][]common.Report)
)
type TerminalSession struct {
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
}
var TerminalSessionsMutex = &sync.Mutex{}
var TerminalSessions = make(map[string]*TerminalSession)
func SaveClientReportToDB() error {
lastMinute := time.Now().Add(-time.Minute * 1).Unix()
record := []models.Record{}
// 遍历所有的客户端记录
for uuid, reports := range Records {
// 删除一分钟前的记录
filtered := reports[:0]
for _, r := range reports {
if r.UpdatedAt.Unix() >= lastMinute {
filtered = append(filtered, r)
}
}
Records[uuid] = filtered
r := utils.AverageReport(uuid, time.Now(), filtered)
record = append(record, r)
db := dbcore.GetDBInstance()
err := db.Model(&models.Record{}).Where("client = ?", uuid).Create(record).Error
if err != nil {
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,
})
}
+54
View File
@@ -0,0 +1,54 @@
package admin
import (
"image/png"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/pquerna/otp/totp"
)
func Generate2FA(c *gin.Context) {
secret, img, err := accounts.Generate2Fa()
if err != nil {
api.RespondError(c, 500, "Failed to generate 2FA: "+err.Error())
return
}
c.SetCookie("2fa_secret", secret, 1800, "/", "", false, true)
c.Header("Content-Type", "image/png")
c.Writer.WriteHeader(200)
png.Encode(c.Writer, img)
}
func Enable2FA(c *gin.Context) {
uuid, _ := c.Get("uuid")
secret, _ := c.Cookie("2fa_secret")
code := c.Query("code")
if secret == "" || uuid == nil || code == "" {
api.RespondError(c, 400, "2FA secret or code not provided")
return
}
if !totp.Validate(code, secret) {
api.RespondError(c, 400, "Invalid 2FA code")
return
}
err := accounts.Enable2Fa(uuid.(string), secret)
if err != nil {
api.RespondError(c, 500, "Failed to enable 2FA: "+err.Error())
return
}
c.SetCookie("2fa_secret", "", -1, "/", "", false, true)
api.RespondSuccess(c, "2FA enabled successfully")
}
func Disable2FA(c *gin.Context) {
uuid, _ := c.Get("uuid")
err := accounts.Disable2Fa(uuid.(string))
if err != nil {
api.RespondError(c, 500, "Failed to disable 2FA: "+err.Error())
return
}
api.RespondSuccess(c, "")
}
+53 -65
View File
@@ -2,80 +2,61 @@ package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/records"
)
func AddClient(c *gin.Context) {
uuid, token, err := clients.CreateClient()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": err.Error()})
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
uuid, token, err := clients.CreateClient()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token})
uuid, token, err := clients.CreateClientWithName(req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
}
func EditClient(c *gin.Context) {
var req struct {
UUID string `json:"uuid" binding:"required"`
ClientName string `json:"name,omitempty"`
Token string `json:"token,omitempty"`
Weigth int `json:"weight,omitempty"`
}
var req = make(map[string]interface{})
uuid := c.Param("uuid")
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": err.Error()})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()})
return
}
db := dbcore.GetDBInstance()
var err error
if req.ClientName != "" {
err = db.Model(&common.ClientInfo{}).Where("uuid = ?", req.UUID).
Updates(map[string]interface{}{"name": req.ClientName, "updated_at": time.Now()}).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": err.Error()})
return
}
if uuid == "" {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Invalid or missing UUID"})
return
}
if req.Token != "" {
err = db.Model(&models.Client{}).Where("uuid = ?", req.UUID).
Updates(map[string]interface{}{"token": req.Token, "updated_at": time.Now()}).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": err.Error()})
return
}
req["uuid"] = uuid
err := clients.SaveClient(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
if req.Weigth != 0 {
err = db.Model(&common.ClientInfo{}).Where("uuid = ?", req.UUID).
Updates(map[string]interface{}{"weigth": req.Weigth, "updated_at": time.Now()}).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": err.Error()})
return
}
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
func RemoveClient(c *gin.Context) {
var req struct {
UUID string `json:"uuid" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{
"status": "error",
"error": "Invalid or missing UUID",
})
}
err := clients.DeleteClient(req.UUID)
uuid := c.Param("uuid")
err := clients.DeleteClient(uuid)
if err != nil {
c.JSON(500, gin.H{
"status": "error",
@@ -83,33 +64,40 @@ func RemoveClient(c *gin.Context) {
})
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
c.JSON(200, gin.H{"status": "success"})
}
func ClearRecord(c *gin.Context) {
if err := records.DeleteAll(); err != nil {
c.JSON(500, gin.H{
"status": "error",
"error": "Failed to delete Record" + err.Error(),
"status": "error",
"message": "Failed to delete Record" + err.Error(),
})
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
c.JSON(200, gin.H{"status": "success"})
}
func GetClient(c *gin.Context) {
uuid := c.Query("uuid")
uuid := c.Param("uuid")
if uuid == "" {
c.JSON(400, gin.H{
"status": "error",
"error": "Invalid or missing UUID",
"status": "error",
"message": "Invalid or missing UUID",
})
return
}
result, err := clients.GetClientByUUID(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{
"status": "error",
"message": err.Error(),
})
return
}
@@ -119,7 +107,7 @@ func GetClient(c *gin.Context) {
func ListClients(c *gin.Context) {
cls, err := clients.GetAllClientBasicInfo()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
@@ -127,20 +115,20 @@ func ListClients(c *gin.Context) {
}
func GetClientToken(c *gin.Context) {
uuid := c.Query("uuid")
uuid := c.Param("uuid")
if uuid == "" {
c.JSON(400, gin.H{
"status": "error",
"error": "Invalid or missing UUID",
"status": "error",
"message": "Invalid or missing UUID",
})
return
}
token, err := clients.GetClientTokenByUUID(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success", "token": token})
c.JSON(http.StatusOK, gin.H{"status": "success", "token": token, "message:": ""})
}
+28
View File
@@ -0,0 +1,28 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
)
func OrderWeight(c *gin.Context) {
var req = make(map[string]int)
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
db := dbcore.GetDBInstance()
for uuid, weight := range req {
err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error
if err != nil {
api.RespondError(c, 500, "Failed to update client weight: "+err.Error())
return
}
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "order clients", "info")
api.RespondSuccess(c, nil)
}
+78
View File
@@ -0,0 +1,78 @@
package admin
import (
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
)
// 接受数据类型:
// - command: string
// - clients: []string (客户端 UUID 列表)
func Exec(c *gin.Context) {
var req struct {
Command string `json:"command" binding:"required"`
Clients []string `json:"clients" binding:"required"`
}
var onlineClients []string
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
for uuid := range ws.ConnectedClients {
if contain(req.Clients, uuid) {
onlineClients = append(onlineClients, uuid)
} else {
api.RespondError(c, 400, "Client not connected: "+uuid)
return
}
}
if len(onlineClients) == 0 {
api.RespondError(c, 400, "No clients connected")
return
}
taskId := utils.GenerateRandomString(16)
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
api.RespondError(c, 500, "Failed to create task: "+err.Error())
return
}
for _, uuid := range onlineClients {
var send struct {
Message string `json:"message"`
Command string `json:"command"`
TaskId string `json:"task_id"`
}
send.Message = "exec"
send.Command = req.Command
send.TaskId = taskId
payload, _ := json.Marshal(send)
client := ws.ConnectedClients[uuid]
if client != nil {
client.WriteMessage(websocket.TextMessage, payload)
} else {
api.RespondError(c, 400, "Client connection is null: "+uuid)
return
}
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
api.RespondSuccess(c, gin.H{
"task_id": taskId,
"clients": onlineClients,
})
}
func contain(clients []string, uuid string) bool {
for _, client := range clients {
if client == uuid {
return true
}
}
return false
}
+49
View File
@@ -0,0 +1,49 @@
package log
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetLogs(c *gin.Context) {
limit := c.Query("limit")
if limit == "" {
limit = "100" // Default to 100 logs if not specified
}
page := c.Query("page")
if page == "" {
page = "1" // Default to page 1 if not specified
}
// Convert limit and page to integers
// If conversion fails, return an error
limitInt, err := strconv.Atoi(limit)
if err != nil || limitInt <= 0 {
api.RespondError(c, 400, "Invalid limit: "+limit)
return
}
pageInt, err := strconv.Atoi(page)
if err != nil || pageInt <= 0 {
api.RespondError(c, 400, "Invalid page: "+page)
return
}
db := dbcore.GetDBInstance()
var logs []models.Log
// 添加分页:计算偏移量并限制数量
offset := (pageInt - 1) * limitInt
var total int64
if err := db.Model(&models.Log{}).Count(&total).Error; err != nil {
api.RespondError(c, 500, "Failed to count logs: "+err.Error())
return
}
if err := db.Order("time desc").Limit(limitInt).Offset(offset).Find(&logs).Error; err != nil {
api.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{"logs": logs, "total": total})
}
+35
View File
@@ -0,0 +1,35 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
)
func BindingExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil {
api.RespondError(c, 500, "No user found: "+err.Error())
return
}
c.SetCookie("binding_external_account", user.UUID, 3600, "/", "", false, true)
c.Redirect(302, "/api/oauth")
}
func UnbindExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil {
api.RespondError(c, 500, "No user found: "+err.Error())
return
}
err = accounts.UnbindExternalAccount(user.UUID)
if err != nil {
api.RespondError(c, 500, "Failed to unbind external account: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
+21 -17
View File
@@ -1,7 +1,9 @@
package admin
import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/gin-gonic/gin"
)
@@ -10,13 +12,11 @@ func GetSessions(c *gin.Context) {
ss, err := accounts.GetAllSessions()
if err != nil {
c.JSON(500, gin.H{
"status": "error",
"error": "Failed to get sessions",
})
api.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
return
}
c.JSON(200, ss)
current, _ := c.Cookie("session_token")
c.JSON(200, gin.H{"status": "success", "current": current, "data": ss})
}
func DeleteSession(c *gin.Context) {
@@ -24,23 +24,27 @@ func DeleteSession(c *gin.Context) {
Session string `json:"session" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{
"status": "error",
"error": "Invalid or missing session",
})
api.RespondError(c, 400, "Invalid request: "+err.Error())
return
}
err := accounts.DeleteSession(req.Session)
if err != nil {
c.JSON(500, gin.H{
"status": "error",
"error": "Failed to delete session",
})
api.RespondError(c, 500, "Failed to delete session: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "delete session", "info")
api.RespondSuccess(c, nil)
}
c.JSON(200, gin.H{
"status": "success",
"message": "Session deleted successfully",
})
func DeleteAllSession(c *gin.Context) {
err := accounts.DeleteAllSessions()
if err != nil {
api.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
api.RespondSuccess(c, nil)
}
+35 -17
View File
@@ -3,7 +3,9 @@ package admin
import (
"database/sql"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
"github.com/gin-gonic/gin"
@@ -18,36 +20,52 @@ func GetSettings(c *gin.Context) {
cst = models.Config{Sitename: "Komari"}
cst.ID = 1
config.Save(cst)
c.JSON(200, cst)
api.RespondSuccess(c, cst)
return
}
c.JSON(500, gin.H{
"status": "error",
"error": "Internal Server Error: " + err.Error(),
"status": "error",
"message": "Internal Server Error: " + err.Error(),
})
}
c.JSON(200, cst)
api.RespondSuccess(c, cst)
}
// EditSettings 更新自定义配置
// 务必传全数据
func EditSettings(c *gin.Context) {
cfg := models.Config{}
cfg := make(map[string]interface{})
if err := c.ShouldBindJSON(&cfg); err != nil {
c.JSON(400, gin.H{
"status": "error",
"error": "Bad Request: " + err.Error(),
})
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
cfg.ID = 1 // Only one record
if err := config.Save(cfg); err != nil {
c.JSON(500, gin.H{
"status": "error",
"error": "Internal Server Error: " + err.Error(),
})
cfg["id"] = 1 // Only one record
if err := config.Update(cfg); err != nil {
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
return
}
c.JSON(200, gin.H{"status": "success"})
uuid, _ := c.Get("uuid")
message := "update settings: "
for key := range cfg {
ignoredKeys := []string{"id", "updated_at"}
if contains(ignoredKeys, key) {
continue
}
message += key + ", "
}
if len(message) > 2 {
message = message[:len(message)-2]
}
logOperation.Log(c.ClientIP(), uuid.(string), message, "info")
api.RespondSuccess(c, nil)
}
func contains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
+154
View File
@@ -0,0 +1,154 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
)
func GetTasks(c *gin.Context) {
dbTasks, err := tasks.GetAllTasks()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
var responseTasks []gin.H
for _, t := range dbTasks {
results, err := tasks.GetTaskResultsByTaskId(t.TaskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
var filteredResults []gin.H
for _, r := range results {
filteredResults = append(filteredResults, gin.H{
"client": r.Client,
"result": r.Result,
"exit_code": r.ExitCode,
"finished_at": r.FinishedAt,
"created_at": r.CreatedAt,
})
}
responseTasks = append(responseTasks, gin.H{
"task_id": t.TaskId,
"clients": t.Clients,
"command": t.Command,
"results": filteredResults,
})
}
api.RespondSuccess(c, responseTasks)
}
func GetTaskById(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
return
}
task, err := tasks.GetTaskByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
return
}
if task == nil {
api.RespondError(c, 404, "Task not found")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
var filteredResults []gin.H
for _, r := range results {
filteredResults = append(filteredResults, gin.H{
"client": r.Client,
"result": r.Result,
"exit_code": r.ExitCode,
"finished_at": r.FinishedAt,
"created_at": r.CreatedAt,
})
}
api.RespondSuccess(c, gin.H{
"task_id": task.TaskId,
"clients": task.Clients,
"command": task.Command,
"results": filteredResults,
})
}
func GetTasksByClientId(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
return
}
tasks, err := tasks.GetTasksByClientId(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(tasks) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, tasks)
}
func GetSpecificTaskResult(c *gin.Context) {
taskId := c.Param("task_id")
clientId := c.Param("uuid")
if taskId == "" || clientId == "" {
api.RespondError(c, 400, "Task ID and Client ID are required")
return
}
result, err := tasks.GetSpecificTaskResult(taskId, clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
return
}
if result == nil {
api.RespondError(c, 404, "No result found for this task and client")
return
}
api.RespondSuccess(c, result)
}
// Param: task_id
func GetTaskResultsByTaskId(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No results found for this task")
return
}
api.RespondSuccess(c, results)
}
func GetAllTaskResultByUUID(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
return
}
results, err := tasks.GetAllTasksResultByUUID(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, results)
}
+47
View File
@@ -0,0 +1,47 @@
package test
import (
"net"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/telegram"
)
func TestTelegram(c *gin.Context) {
message := "Komari test message."
err := telegram.SendTextMessage(message)
if err != nil {
api.RespondError(c, 500, "Failed to send Telegram message: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func TestGeoIp(c *gin.Context) {
ip := c.Query("ip")
if ip == "" {
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
ip = cfIP
} else {
ip = c.ClientIP()
}
}
conf, err := config.Get()
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if !conf.GeoIpEnabled {
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.GetGeoIpInfo(net.ParseIP(ip))
if err != nil {
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
}
api.RespondSuccess(c, GeoIpRecord)
}
+46
View File
@@ -0,0 +1,46 @@
package update
import (
"io"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
)
func UploadFavicon(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) // 5MB
data, err := io.ReadAll(c.Request.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
api.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
} else {
api.RespondError(c, http.StatusBadRequest, err.Error())
}
return
}
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
api.RespondSuccess(c, nil)
}
func DeleteFavicon(c *gin.Context) {
if err := os.Remove("./data/favicon.ico"); err != nil {
if os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "Favicon not found")
} else {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
}
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
api.RespondSuccess(c, nil)
}
+18
View File
@@ -0,0 +1,18 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/utils/geoip"
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateGeoIpDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
api.RespondSuccess(c, nil)
}
+40
View File
@@ -0,0 +1,40 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/logOperation"
)
func UpdateUser(c *gin.Context) {
var req struct {
Uuid string `json:"uuid" binding:"required"`
Name *string `json:"username"`
Password *string `json:"password"`
SsoType *string `json:"sso_type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
if req.Password == nil && req.Name == nil {
api.RespondError(c, 400, "At least one field (username or password) must be provided")
return
}
if req.Name != nil && len(*req.Name) < 3 {
api.RespondError(c, 400, "Username must be at least 3 characters long")
return
}
if req.Password != nil && len(*req.Password) < 6 {
api.RespondError(c, 400, "Password must be at least 6 characters long")
return
}
if err := accounts.UpdateUser(req.Uuid, req.Name, req.Password, req.SsoType); err != nil {
api.RespondError(c, 500, "Failed to update user: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
}
+18 -5
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/ws"
@@ -38,13 +39,14 @@ func UploadReport(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = clients.SaveClientReport(report.UUID, report)
report.UpdatedAt = time.Now()
err = SaveClientReport(report.UUID, report)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
return
}
// Update report with method and token
report.UpdatedAt = time.Now()
ws.LatestReport[report.UUID] = &report
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
@@ -124,13 +126,24 @@ func WebSocketReport(c *gin.Context) {
if err != nil {
break
}
err = clients.SaveClientReport(uuid, report)
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
}
report.UpdatedAt = time.Now()
ws.LatestReport[uuid] = &report
}
}
func SaveClientReport(uuid string, report common.Report) error {
if api.Records[uuid] == nil {
api.Records[uuid] = []common.Report{}
}
if report.CPU.Usage < 0.01 {
report.CPU.Usage = 0.01
}
api.Records[uuid] = append(api.Records[uuid], report)
return nil
}
+35
View File
@@ -0,0 +1,35 @@
package client
import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/tasks"
)
func TaskResult(c *gin.Context) {
token := c.Query("token")
clientId, _ := clients.GetClientUUIDByToken(token)
if clientId == "" {
c.JSON(400, gin.H{"status": "error", "message": "Invalid or missing token"})
return
}
var req struct {
TaskId string `json:"task_id" binding:"required"`
Result string `json:"result" binding:"required"`
ExitCode int `json:"exit_code"`
FinishedAt time.Time `json:"finished_at" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"status": "error", "message": "Invalid request"})
return
}
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, req.FinishedAt); err != nil {
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
return
}
c.JSON(200, gin.H{"status": "success", "message": "Task result updated successfully"})
}
+48
View File
@@ -0,0 +1,48 @@
package client
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
)
func EstablishConnection(c *gin.Context) {
session_id := c.Query("id")
session, exists := api.TerminalSessions[session_id]
if !exists || session == nil || session.Browser == nil {
c.JSON(404, gin.H{"status": "error", "error": "Session not found"})
return
}
// Upgrade the connection to WebSocket
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // TODO: 检查源站
},
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
api.TerminalSessionsMutex.Lock()
if session.Browser != nil {
session.Browser.Close()
}
delete(api.TerminalSessions, session_id)
api.TerminalSessionsMutex.Unlock()
return
}
session.Agent = conn
conn.SetCloseHandler(func(code int, text string) error {
delete(api.TerminalSessions, session_id)
// 通知 Browser 关闭终端连接
if session.Browser != nil {
session.Browser.Close()
}
return nil
})
go api.ForwardTerminal(session_id)
}
+9 -10
View File
@@ -3,7 +3,6 @@ package client
import (
"net"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/geoip"
@@ -12,7 +11,7 @@ import (
)
func UploadBasicInfo(c *gin.Context) {
var cbi = common.ClientInfo{}
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
@@ -25,25 +24,25 @@ func UploadBasicInfo(c *gin.Context) {
return
}
cbi.UUID = uuid
cbi["uuid"] = uuid
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if cbi.IPv4 != "" {
ip4 := net.ParseIP(cbi.IPv4)
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoIpInfo(ip4)
if ip4_record != nil {
cbi.Region = geoip.GetRegionUnicodeEmoji(ip4_record.Country.ISOCode)
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.Country.ISOCode)
}
} else if cbi.IPv6 != "" {
ip6 := net.ParseIP(cbi.IPv6)
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
ip6 := net.ParseIP(ipv6)
ip6_record, _ := geoip.GetGeoIpInfo(ip6)
if ip6_record != nil {
cbi.Region = geoip.GetRegionUnicodeEmoji(ip6_record.Country.ISOCode)
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.Country.ISOCode)
}
}
}
if err := clients.UpdateOrInsertBasicInfo(cbi); err != nil {
if err := clients.SaveClientInfo(cbi); err != nil {
c.JSON(500, gin.H{"status": "error", "error": err})
return
}
+14
View File
@@ -0,0 +1,14 @@
package api
import (
"github.com/gin-gonic/gin"
)
func GetClientRecentRecords(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
RespondError(c, 400, "UUID is required")
return
}
RespondSuccess(c, Records[uuid])
}
+41 -17
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/gin-gonic/gin"
)
@@ -13,39 +15,61 @@ import (
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TwoFa string `json:"2fa_code"`
}
func Login(c *gin.Context) {
// TODO: Settings -> Disable password login when using OAuth
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 {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Invalid request body"})
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
var data LoginRequest
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Invalid request body"})
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
if data.Username == "" || data.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Invalid request body"})
RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
return
}
if uuid, success := accounts.CheckPassword(data.Username, data.Password); success {
session, err := accounts.CreateSession(uuid, 2592000)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "Failed to create session" + err.Error()})
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
c.JSON(200, gin.H{"set-cookie": map[string]interface{}{"session_token": session}})
uuid, success := accounts.CheckPassword(data.Username, data.Password)
if !success {
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
return
} else {
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Invalid credentials"})
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" && data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
accounts.DeleteSession(session)
c.SetCookie("session_token", "", -1, "/", "", false, true)
logOperation.Log(c.ClientIP(), "", "logged out", "logout")
c.Redirect(302, "/")
}
+1 -1
View File
@@ -22,6 +22,6 @@ func GetMe(c *gin.Context) {
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
return
}
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID})
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID, "2fa_enabled": user.TwoFactor != ""})
}
+1 -1
View File
@@ -21,7 +21,7 @@ func TestGetMe(t *testing.T) {
t.Fatalf("Failed to create test user: %v", err)
}
uuid := user.UUID
sessionToken, _ := accounts.CreateSession(uuid, 2592000)
sessionToken, _ := accounts.CreateSession(uuid, 2592000, "test_user_agent", "127.0.0.1", "oauth")
tests := []struct {
name string
+6 -3
View File
@@ -8,16 +8,19 @@ import (
func GetNodesInformation(c *gin.Context) {
clientList, err := clients.GetAllClientBasicInfo()
if err != nil {
c.JSON(500, gin.H{"status": "error", "error": err.Error()})
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
return
}
count := len(clientList)
// 公开信息不展示IP地址
// 公开信息不展示IP地址,私有备注不展示
for i := 0; i < count; i++ {
clientList[i].IPv4 = ""
clientList[i].IPv6 = ""
clientList[i].Remark = ""
clientList[i].Version = ""
clientList[i].Token = ""
}
c.JSON(200, gin.H{"status": "success", "data": clientList})
RespondSuccess(c, clientList)
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/utils/oauth"
)
// /api/oauth
func OAuth(c *gin.Context) {
cfg, _ := config.Get()
if !cfg.OAuthEnabled {
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
return
}
authURL, state := oauth.GetOAuthUrl()
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
c.Redirect(302, authURL)
}
// /api/oauth_callback
func OAuthCallback(c *gin.Context) {
// 验证state防止CSRF攻击
state, _ := c.Cookie("oauth_state")
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
// 获取code
code := c.Query("code")
if code == "" {
c.JSON(400, gin.H{"status": "error", "error": "No code provided"})
return
}
githubUser, err := oauth.OAuthCallback(code, state)
if err != nil {
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
return
}
// 使用GitHub ID作为SSO ID
sso_id := fmt.Sprintf("github_%d", githubUser.ID)
// 如果cookie中有binding_external_account,说明是绑定外部账号
// 否则是登录
uuid, _ := c.Cookie("binding_external_account")
c.SetCookie("binding_external_account", "", -1, "/", "", false, true)
if uuid != "" {
// 绑定外部账号
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil || user.UUID != uuid {
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
return
}
err = accounts.BindingExternalAccount(user.UUID, sso_id)
if err != nil {
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
return
}
logOperation.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
c.Redirect(302, "/manage")
return
}
// 尝试获取用户
user, err := accounts.GetUserBySSO(sso_id)
if err != nil {
c.JSON(401, gin.H{
"status": "error",
"message": "please log in and bind your GitHub account first.",
})
return
}
// 创建会话
session, err := accounts.CreateSession(user.UUID, 2592000, c.Request.UserAgent(), c.ClientIP(), "oauth")
if err != nil {
c.JSON(500, gin.H{"status": "error", "message": err.Error()})
return
}
// 设置cookie并返回
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
c.Redirect(302, "/manage")
}
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
)
func GetPublicSettings(c *gin.Context) {
cst, err := config.Get()
if err != nil {
RespondError(c, 500, err.Error())
return
}
// Return public settings including CORS
RespondSuccess(c, gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
})
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
)
func RequestTerminal(c *gin.Context) {
uuid := c.Param("uuid")
user_uuid, _ := c.Get("uuid")
_, err := clients.GetClientByUUID(uuid)
if err != nil {
c.JSON(400, gin.H{
"status": "error",
"message": "Client not found",
})
return
}
// 建立ws
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Require WebSocket upgrade"})
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
// 新建一个终端连接
id := utils.GenerateRandomString(32)
session := &TerminalSession{
UserUUID: user_uuid.(string),
UUID: uuid,
Browser: conn,
Agent: nil,
}
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.ConnectedClients[uuid] == nil {
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!"))
conn.Close()
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
return
}
err = ws.ConnectedClients[uuid].WriteJSON(gin.H{
"message": "terminal",
"request_id": id,
})
if err != nil {
conn.Close()
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
return
}
conn.WriteMessage(1, []byte("等待被控端连接 waiting for agent..."))
// 如果没有连接上,则关闭连接
time.AfterFunc(30*time.Second, func() {
TerminalSessionsMutex.Lock()
if session.Agent == nil {
if session.Browser != nil {
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout"))
session.Browser.Close()
}
conn.Close()
delete(TerminalSessions, id)
}
TerminalSessionsMutex.Unlock()
})
logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
}
func ForwardTerminal(id string) {
session, exists := TerminalSessions[id]
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
return
}
logOperation.Log("", session.UserUUID, "established, terminal id:"+id, "terminal")
established_time := time.Now()
errChan := make(chan error, 1)
go func() {
for {
messageType, data, err := session.Browser.ReadMessage()
if err != nil {
errChan <- err
return
}
if messageType == websocket.TextMessage {
if session.Agent != nil && string(data[0:1]) == "{" {
err = session.Agent.WriteMessage(websocket.TextMessage, data)
} else if session.Agent != nil {
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
} else if session.Agent != nil {
// 二进制消息,原样传递
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
if err != nil {
errChan <- err
return
}
}
}()
go func() {
for {
_, data, err := session.Agent.ReadMessage()
if err != nil {
errChan <- err
return
}
if session.Browser != nil {
err = session.Browser.WriteMessage(websocket.BinaryMessage, data)
if err != nil {
errChan <- err
return
}
}
}
}()
// 等待错误或主动关闭
<-errChan
// 关闭连接
if session.Agent != nil {
session.Agent.Close()
}
if session.Browser != nil {
session.Browser.Close()
}
disconnect_time := time.Now()
logOperation.Log("", session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
}
+10 -2
View File
@@ -1,6 +1,14 @@
package flags
var (
DatabaseFile string
Listen string
// 数据库配置
DatabaseType string // 数据库类型:sqlite, mysql
DatabaseFile string // SQLite数据库文件路径
DatabaseHost string // MySQL/其他数据库主机地址
DatabasePort string // MySQL/其他数据库端口
DatabaseUser string // MySQL/其他数据库用户名
DatabasePass string // MySQL/其他数据库密码
DatabaseName string // MySQL/其他数据库名称
Listen string
)
+33
View File
@@ -0,0 +1,33 @@
package cmd
import (
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
var PermitPasswordLoginCmd = &cobra.Command{
Use: "permit-login",
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 {
cmd.Println("Error:", err)
os.Exit(1)
}
cmd.Println("Password login has been permitted.")
cmd.Println("Please restart the server to apply the changes.")
},
}
func init() {
RootCmd.AddCommand(PermitPasswordLoginCmd)
}
+27 -1
View File
@@ -9,6 +9,25 @@ import (
"github.com/spf13/cobra"
)
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
// 从环境变量获取默认值
var (
dbTypeEnv = getEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = getEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = getEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = getEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = getEnv("KOMARI_DB_USER", "root")
dbPassEnv = getEnv("KOMARI_DB_PASS", "")
dbNameEnv = getEnv("KOMARI_DB_NAME", "komari")
)
var RootCmd = &cobra.Command{
Use: "Komari",
Short: "Komari is a simple server monitoring tool",
@@ -28,5 +47,12 @@ func Execute() {
}
func init() {
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", "./data/komari.db", "Database file")
// 设置命令行参数,提供环境变量作为默认值
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite, mysql) [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]")
}
+175 -47
View File
@@ -1,61 +1,81 @@
package cmd
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/api/admin"
log_api "github.com/komari-monitor/komari/api/admin/log"
"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/cmd/flags"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/ws"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/spf13/cobra"
)
var (
DynamicCorsEnabled bool = false
)
var ServerCmd = &cobra.Command{
Use: "server",
Short: "Start the server",
Long: `Start the server`,
Run: func(cmd *cobra.Command, args []string) {
InitDatabase()
go DoRecordsWork()
go geoip.InitGeoIp()
go DoScheduledWork()
r := gin.Default()
cfg, err := config.Get()
// 动态 CORS 中间件
conf, err := config.Get()
if err != nil {
log.Fatalln("Failed to get config:", err)
log.Fatal(err)
}
if cfg.AllowCros {
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
}
if cfg.GeoIpEnabled {
geoip.InitGeoIp()
go func() {
ticker := time.NewTicker(time.Hour * 24)
for range ticker.C {
geoip.UpdateGeoIpDatabase()
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
DynamicCorsEnabled = event.New.AllowCors
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "43200") // 12 hours
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
}()
}
}
c.Next()
})
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
@@ -65,54 +85,145 @@ var ServerCmd = &cobra.Command{
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", ws.GetClients)
r.GET("/api/nodes", api.GetNodesInformation)
r.GET("/api/public", api.GetPublicSettings)
r.GET("/api/oauth", api.OAuth)
r.GET("/api/oauth_callback", api.OAuthCallback)
r.GET("/api/logout", api.Logout)
r.GET("/api/version", api.GetVersion)
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
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)
}
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
// clients
adminAuthrized.POST("/addClient", admin.AddClient)
adminAuthrized.POST("/editClient", admin.EditClient)
adminAuthrized.GET("/listClients", admin.ListClients)
adminAuthrized.GET("/getClient", admin.GetClient)
adminAuthrized.POST("/clearRecord", admin.ClearRecord)
adminAuthrized.POST("/removeClient", admin.RemoveClient)
adminAuthrized.GET("/clientToken", admin.GetClientToken)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/telegram", test.TestTelegram)
testGroup.GET("/geoip", test.TestGeoIp)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
adminAuthrized.GET("/settings", admin.GetSettings)
adminAuthrized.POST("/settings", admin.EditSettings)
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
// 静态文件服务
public.UpdateIndex(conf)
config.Subscribe(func(event config.ConfigEvent) {
public.UpdateIndex(event.New)
})
srv := &http.Server{
Addr: flags.Listen,
Handler: r,
}
go func() {
cfg, err := config.Get()
if err != nil {
log.Fatalln("Failed to get config:", err)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
log.Fatalf("listen: %s\n", err)
}
public.UpdateIndex(cfg)
}()
r.Run(flags.Listen)
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
OnShutdown()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
},
}
func init() {
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", "0.0.0.0:25774", "Listen address")
// 从环境变量获取监听地址
listenAddr := getEnv("KOMARI_LISTEN", "0.0.0.0:25774")
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
RootCmd.AddCommand(ServerCmd)
}
func InitDatabase() {
if !dbcore.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()
if err != nil {
panic(err)
@@ -121,12 +232,29 @@ func InitDatabase() {
}
}
func DoRecordsWork() {
func DoScheduledWork() {
ticker := time.NewTicker(time.Minute * 30)
ticker1 := time.NewTicker(60 * time.Second)
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
for range ticker.C {
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
for {
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * 24 * 30))
logOperation.RemoveOldLogs()
case <-ticker1.C:
api.SaveClientReportToDB()
}
}
}
func OnShutdown() {
logOperation.Log("", "", "server is shutting down", "info")
}
func OnFatal(err error) {
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
}
+24 -18
View File
@@ -29,25 +29,31 @@ type ClientConfig struct {
}
// ClientInfo stores static client information
// Deprecated: Use models.Client instead.
type ClientInfo struct {
UUID string `json:"uuid,omitempty" gorm:"type:uuid;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)"`
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"`
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" gorm:"type:varchar(100)"`
Weigth int `json:"weigth" gorm:"type:int"`
CreatedAt time.Time
UpdatedAt time.Time
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 {
+58
View File
@@ -0,0 +1,58 @@
package accounts
import (
"image"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/pquerna/otp/totp"
)
var (
TwoFactorIssuer = "Komari Monitor"
)
func Generate2Fa() (string, image.Image, error) {
otp, err := totp.Generate(totp.GenerateOpts{
Issuer: TwoFactorIssuer,
AccountName: "komari",
})
if err != nil {
return "", nil, err
}
img, err := otp.Image(250, 250)
if err != nil {
return "", nil, err
}
return otp.Secret(), img, nil
}
func Enable2Fa(uuid, secret string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", secret).Error
}
func Verify2Fa(uuid, code string) (bool, error) {
db := dbcore.GetDBInstance()
var user models.User
err := db.Where("uuid = ?", uuid).First(&user).Error
if err != nil {
return false, err
}
if user.TwoFactor == "" {
return false, nil // 用户未启用2FA
}
valid := totp.Validate(code, user.TwoFactor)
if !valid {
return false, nil
}
return true, nil
}
func Disable2Fa(uuid string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", "").Error
}
+50 -2
View File
@@ -122,7 +122,7 @@ func GetUserByUUID(uuid string) (user models.User, err error) {
}
// 通过 SSO 信息获取用户
func GetUserBySSO(ssoID, username string) (user models.User, err error) {
func GetUserBySSO(ssoID string) (user models.User, err error) {
db := dbcore.GetDBInstance()
// 首先尝试查找已存在的用户
@@ -131,5 +131,53 @@ func GetUserBySSO(ssoID, username string) (user models.User, err error) {
return user, nil
}
return user, nil
// 如果找不到用户,返回明确的错误信息
return models.User{}, fmt.Errorf("用户不存在:%s", ssoID)
}
func BindingExternalAccount(uuid string, sso_id string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", sso_id).Error
if err != nil {
return err
}
return nil
}
func UnbindExternalAccount(uuid string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", "").Error
if err != nil {
return err
}
return nil
}
func UpdateUser(uuid string, name, password, sso_type *string) error {
db := dbcore.GetDBInstance()
// Check if user exists
var existingUser models.User
result := db.Where("uuid = ?", uuid).First(&existingUser)
if result.Error != nil {
return fmt.Errorf("user not found: %s", uuid)
}
updates := make(map[string]interface{})
if name != nil {
updates["username"] = *name
}
if password != nil {
updates["passwd"] = hashPasswd(*password)
}
if sso_type != nil {
updates["sso_type"] = *sso_type
}
updates["updated_at"] = time.Now()
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error
if err != nil {
return err
}
if password != nil {
DeleteAllSessions()
}
return nil
}
+31 -4
View File
@@ -20,14 +20,18 @@ func GetAllSessions() (sessions []models.Session, err error) {
}
// CreateSession 创建新会话
func CreateSession(uuid string, expires int) (string, error) {
func CreateSession(uuid string, expires int, userAgent, ip, login_method string) (string, error) {
db := dbcore.GetDBInstance()
session := utils.GenerateRandomString(32)
sessionRecord := models.Session{
UUID: uuid,
Session: session,
Expires: time.Now().Add(time.Duration(expires) * time.Second),
UUID: uuid,
Session: session,
Expires: time.Now().Add(time.Duration(expires) * time.Second),
UserAgent: userAgent,
Ip: ip,
LoginMethod: login_method,
LatestOnline: time.Now(),
}
err := db.Create(&sessionRecord).Error
@@ -83,3 +87,26 @@ func DeleteAllSessions() error {
}
return nil
}
func UpdateLatestOnline(session string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_online", time.Now()).Error
}
func UpdateLatestUserAgent(session, userAgent string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_user_agent", userAgent).Error
}
func UpdateLatestIp(session, ip string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_ip", ip).Error
}
func UpdateLatest(session, useragent, ip string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Session{}).Where("session = ?", session).Updates(map[string]interface{}{
"latest_online": time.Now(),
"latest_user_agent": useragent,
"latest_ip": ip,
}).Error
}
+96 -16
View File
@@ -7,8 +7,11 @@ import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fmt"
"github.com/google/uuid"
)
@@ -27,14 +30,10 @@ func DeleteClient(clientUuid string) error {
if err != nil {
return err
}
err = db.Delete(&common.ClientInfo{}, "uuid = ?", clientUuid).Error
if err != nil {
return err
}
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{})
@@ -75,10 +74,53 @@ func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
updates["version"] = cbi.Version
updates["updated_at"] = time.Now()
err := db.Clauses(clause.OnConflict{
// 转换为更新Client表
client := models.Client{
UUID: cbi.UUID,
}
err := db.Model(&client).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(updates),
}).Create(&cbi).Error
}).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)
if !ok || clientUUID == "" {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(update) == 0 {
return fmt.Errorf("no fields to update")
}
update["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
}
@@ -97,7 +139,7 @@ func UpdateClientConfig(config common.ClientConfig) error {
func EditClientName(clientUUID, clientName string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("client_name", clientName).Error
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
if err != nil {
return err
}
@@ -137,6 +179,7 @@ func CreateClient() (clientUUID, token string, err error) {
client := models.Client{
UUID: clientUUID,
Token: token,
Name: "client_" + clientUUID[0:8],
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -145,11 +188,25 @@ func CreateClient() (clientUUID, token string, err error) {
if err != nil {
return "", "", err
}
clientInfo := common.ClientInfo{
UUID: clientUUID,
Name: "client_" + clientUUID[0:8],
return clientUUID, token, nil
}
func CreateClientWithName(name string) (clientUUID, token string, err error) {
if name == "" {
return CreateClient()
}
err = db.Create(&clientInfo).Error
db := dbcore.GetDBInstance()
token = utils.GenerateToken()
clientUUID = uuid.New().String()
client := models.Client{
UUID: clientUUID,
Token: token,
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
@@ -178,13 +235,15 @@ func GetClientByUUID(uuid string) (client models.Client, err error) {
}
// GetClientBasicInfo 获取指定 UUID 的客户端基本信息
func GetClientBasicInfo(uuid string) (client common.ClientInfo, err error) {
func GetClientBasicInfo(uuid string) (client models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return client, err
if err == gorm.ErrRecordNotFound {
return models.Client{}, fmt.Errorf("客户端不存在: %s", uuid)
}
return models.Client{}, err
}
return client, nil
}
@@ -198,7 +257,7 @@ func GetClientTokenByUUID(uuid string) (token string, err error) {
return client.Token, nil
}
func GetAllClientBasicInfo() (clients []common.ClientInfo, err error) {
func GetAllClientBasicInfo() (clients []models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Find(&clients).Error
if err != nil {
@@ -206,3 +265,24 @@ func GetAllClientBasicInfo() (clients []common.ClientInfo, err error) {
}
return clients, nil
}
func SaveClient(updates map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := updates["uuid"].(string)
if !ok || clientUUID == "" {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(updates) == 0 {
return fmt.Errorf("no fields to update")
}
updates["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
if err != nil {
return err
}
return nil
}
+16 -16
View File
@@ -58,23 +58,23 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
Record := models.Record{
Client: clientUUID,
Time: time.Now(),
CPU: float32(report.CPU.Usage),
GPU: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
RAM: report.Ram.Used,
RAMTotal: report.Ram.Total,
SWAP: report.Swap.Used,
SWAPTotal: report.Swap.Total,
LOAD: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
TEMP: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
DISK: report.Disk.Used,
DISKTotal: report.Disk.Total,
NETIn: report.Network.Down,
NETOut: report.Network.Up,
NETTotalUp: report.Network.TotalUp,
NETTotalDown: report.Network.TotalDown,
PROCESS: report.Process,
Cpu: float32(report.CPU.Usage),
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
Ram: report.Ram.Used,
RamTotal: report.Ram.Total,
Swap: report.Swap.Used,
SwapTotal: report.Swap.Total,
Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
Disk: report.Disk.Used,
DiskTotal: report.Disk.Total,
NetIn: report.Network.Down,
NetOut: report.Network.Up,
NetTotalUp: report.Network.TotalUp,
NetTotalDown: report.Network.TotalDown,
Process: report.Process,
Connections: report.Connections.TCP,
ConnectionsUDP: report.Connections.UDP,
ConnectionsUdp: report.Connections.UDP,
}
// 使用事务确保 Record 和 ClientsInfo 一致性
+73 -7
View File
@@ -1,9 +1,12 @@
package config
import (
"errors"
"log"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/public"
"gorm.io/gorm"
)
@@ -13,9 +16,19 @@ func Get() (models.Config, error) {
if err := db.First(&config).Error; err != nil {
if err == gorm.ErrRecordNotFound {
config = models.Config{
ID: 1,
ID: 1,
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "mmdb",
UpdatedAt: time.Now(),
CreatedAt: time.Now(),
}
if err := db.Create(&config).Error; err != nil {
log.Fatal("Failed to create default config:", err)
}
Save(config)
return config, nil
}
return config, err
@@ -25,12 +38,65 @@ func Get() (models.Config, error) {
func Save(cst models.Config) error {
db := dbcore.GetDBInstance()
var config models.Config
oldConfig, _ := Get()
// Only one records
config.ID = 1
if err := db.Save(&config).Error; err != nil {
cst.ID = 1
cst.UpdatedAt = time.Now()
// Do not update CreatedAt
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
Select("sitename",
"description",
"allow_cors",
"geo_ip_enabled",
"geo_ip_provider",
"o_auth_client_id",
"o_auth_client_secret",
"o_auth_enabled",
"custom_head",
"updated_at").
Updates(cst).Error; err != nil {
return err
}
newConfig, _ := Get()
publishEvent(oldConfig, newConfig)
return nil
}
func Update(cst map[string]interface{}) error {
db := dbcore.GetDBInstance()
oldConfig, _ := Get()
// Proceed with update
cst["id"] = 1
cst["updated_at"] = time.Now()
delete(cst, "created_at")
delete(cst, "CreatedAt")
// 至少有一种登录方式启用
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
newOAuthEnabled := oldConfig.OAuthEnabled
if val, exists := cst["disable_password_login"]; exists {
newDisablePasswordLogin = val.(bool)
}
if val, exists := cst["o_auth_enabled"]; exists {
newOAuthEnabled = val.(bool)
}
if newDisablePasswordLogin && !newOAuthEnabled {
return errors.New("at least one login method must be enabled (password/oauth)")
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
return errors.Join(err, errors.New("failed to update configuration"))
}
newConfig := &models.Config{}
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
}
publishEvent(oldConfig, *newConfig)
return nil
})
if err != nil {
return err
}
go public.UpdateIndex(config)
return nil
}
+38
View File
@@ -0,0 +1,38 @@
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)
}
}
+148 -32
View File
@@ -1,67 +1,183 @@
package dbcore
import (
"fmt"
"log"
"os"
"path/filepath"
"sync"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/models"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// 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 = 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")
}
}
var (
instance *gorm.DB
once sync.Once
)
// true if database file exists
//
// false if database file does not exist
func InitDatabase() bool {
if _, err := os.Stat(flags.DatabaseFile); os.IsNotExist(err) {
log.Printf("Database file %q does not exist, creating...", flags.DatabaseFile)
dbDir := filepath.Dir(flags.DatabaseFile)
if dbDir != "" {
if err := os.MkdirAll(dbDir, 0755); err != nil {
log.Fatalf("Failed to create directory %q for database file: %v", dbDir, err)
}
}
file, err := os.Create(flags.DatabaseFile)
if err != nil {
log.Fatalf("Failed to create SQLite3 database file %q: %v", flags.DatabaseFile, err)
}
if err := file.Close(); err != nil {
log.Fatalf("Failed to close database file %q: %v", flags.DatabaseFile, err)
}
return false
} else if err != nil {
log.Fatalf("Failed to check database file %q: %v", flags.DatabaseFile, err)
}
return true
}
// 初始化数据库
// 对于 SQLite:true 如果数据库文件存在,false 如果数据库文件不存在并被创建
// 对于 MySQL/其他数据库:总是返回 true
// func InitDatabase() bool {
// // 默认使用 SQLite 如果未指定类型
// if flags.DatabaseType == "" || flags.DatabaseType == "sqlite" {
// if _, err := os.Stat(flags.DatabaseFile); os.IsNotExist(err) {
// log.Printf("SQLite database file %q does not exist, creating...", flags.DatabaseFile)
// dbDir := filepath.Dir(flags.DatabaseFile)
// if dbDir != "" {
// if err := os.MkdirAll(dbDir, 0755); err != nil {
// log.Fatalf("Failed to create database file directory %q: %v", dbDir, err)
// }
// }
// file, err := os.Create(flags.DatabaseFile)
// if err != nil {
// log.Fatalf("Failed to create SQLite database file %q: %v", flags.DatabaseFile, err)
// }
// if err := file.Close(); err != nil {
// log.Fatalf("Failed to close database file %q: %v", flags.DatabaseFile, err)
// }
// return false
// } else if err != nil {
// log.Fatalf("Failed to check database file %q: %v", flags.DatabaseFile, err)
// }
// return true
// } else if flags.DatabaseType == "mysql" {
// // 对于 MySQL,我们不需要创建文件,只需检查连接信息是否有效
// log.Printf("Using MySQL database: %s@%s:%s/%s",
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
// return true
// } else {
// log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
// return false
// }
// }
func GetDBInstance() *gorm.DB {
once.Do(func() {
var err error
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), &gorm.Config{})
if err != nil {
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
logConfig := &gorm.Config{
//Logger: logger.Default.LogMode(logger.Silent),
}
// 根据数据库类型选择不同的连接方式
switch flags.DatabaseType {
case "sqlite", "":
// SQLite 连接
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
if err != nil {
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
}
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
case "mysql":
// MySQL 连接
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
flags.DatabaseUser,
flags.DatabasePass,
flags.DatabaseHost,
flags.DatabasePort,
flags.DatabaseName)
instance, err = gorm.Open(mysql.Open(dsn), logConfig)
if err != nil {
log.Fatalf("Failed to connect to MySQL database: %v", err)
}
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
default:
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
}
MergeDatabase(instance)
// 自动迁移模型
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Session{},
&models.Record{},
&common.ClientInfo{},
&models.Config{},
&models.Log{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
if err != nil {
log.Printf("Failed to create Session table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Task{},
&models.TaskResult{},
)
if err != nil {
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
}
})
return instance
}
+31
View File
@@ -0,0 +1,31 @@
package logOperation
import (
"log"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func Log(ip, uuid, message, msgType string) {
now := time.Now()
db := dbcore.GetDBInstance()
logEntry := &models.Log{
IP: ip,
UUID: uuid,
Message: message,
MsgType: msgType,
Time: now,
}
db.Create(logEntry)
}
// Delete logs older than 30 days
func RemoveOldLogs() {
db := dbcore.GetDBInstance()
threshold := time.Now().AddDate(0, 0, -30)
if err := db.Where("time < ?", threshold).Delete(&models.Log{}).Error; err != nil {
log.Println("Failed to remove old logs:", err)
}
}
+28
View File
@@ -0,0 +1,28 @@
package models
import "time"
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'mmdb'"` // mmdb, bilibili, ip-api. 暂时只实现了mmdb
// OAuth 配置
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// Telegram Bot 配置
TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
CreatedAt time.Time
UpdatedAt time.Time
}
+12
View File
@@ -0,0 +1,12 @@
package models
import "time"
type Log struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
UUID string `json:"uuid" gorm:"type:varchar(36);"`
Message string `json:"message" gorm:"type:text;not null"`
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
Time time.Time `json:"time" gorm:"autoCreateTime;not null"`
}
+55 -47
View File
@@ -6,69 +6,77 @@ import (
// Client represents a registered client device
type Client struct {
UUID string `json:"uuid,omitempty" gorm:"type:uuid;primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
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 `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// User represents an authenticated user
type User struct {
UUID string `json:"uuid,omitempty" gorm:"type:uuid;primaryKey"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Username string `json:"username" gorm:"type:varchar(50);unique;not null"`
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Session manages user sessions
type Session struct {
UUID string `gorm:"type:uuid;foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
Session string `gorm:"type:varchar(255);unique;not null"`
Expires time.Time `gorm:"not null"`
CreatedAt time.Time
UUID string `json:"uuid" gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
Session string `json:"session" gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
UserAgent string `json:"user_agent" gorm:"type:text"`
Ip string `json:"ip" gorm:"type:varchar(100)"`
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
LatestOnline time.Time `json:"latest_online" gorm:"type:timestamp"`
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
Expires time.Time `json:"expires" gorm:"not null"`
CreatedAt time.Time `json:"created_at"`
}
// Record logs client metrics over time
type Record struct {
Client string `gorm:"type:uuid;index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Time time.Time `gorm:"index;default:CURRENT_TIMESTAMP"`
CPU float32 `gorm:"type:decimal(5,2)"` // e.g., 75.50%
GPU float32 `gorm:"type:decimal(5,2)"`
RAM int64 `gorm:"type:bigint"`
RAMTotal int64 `gorm:"type:bigint"`
SWAP int64 `gorm:"type:bigint"`
SWAPTotal int64 `gorm:"type:bigint"`
LOAD float32 `gorm:"type:decimal(5,2)"`
TEMP float32 `gorm:"type:decimal(5,2)"`
DISK int64 `gorm:"type:bigint"`
DISKTotal int64 `gorm:"type:bigint"`
NETIn int64 `gorm:"type:bigint"`
NETOut int64 `gorm:"type:bigint"`
NETTotalUp int64 `gorm:"type:bigint"`
NETTotalDown int64 `gorm:"type:bigint"`
PROCESS int
Connections int
ConnectionsUDP int
}
// Config stores site-wide settings
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"desc" gorm:"type:text"`
AllowCros bool `json:"allow_cros" gorm:"default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geoip_enable" gorm:"default:true"`
GeoIpProvider string `json:"geoip_provider" gorm:"type:varchar(20);default:'mmdb'"` // mmdb, bilibili, ip-api. 暂时只实现了mmdb
// OAuth 配置
OAuthClientID string `json:"oauth_id" gorm:"type:varchar(255);not null"`
OAuthClientSecret string `json:"oauth_secret" gorm:"type:varchar(255);not null"`
OAuthEnabled bool `json:"oauth_enable" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CreatedAt time.Time
UpdatedAt time.Time
Client string `json:"client" gorm:"type:varchar(36);index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Time time.Time `json:"time" gorm:"index"`
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
Ram int64 `json:"ram" gorm:"type:bigint"`
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
Swap int64 `json:"swap" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
Disk int64 `json:"disk" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
NetIn int64 `json:"net_in" gorm:"type:bigint"`
NetOut int64 `json:"net_out" gorm:"type:bigint"`
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
}
+37
View File
@@ -0,0 +1,37 @@
package models
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// StringArray represents a slice of strings stored as JSON in the database
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")
}
return json.Unmarshal(bytes, sa)
}
func (sa StringArray) Value() (driver.Value, error) {
return json.Marshal(sa)
}
type Task struct {
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Command string `json:"command" gorm:"type:text"`
}
type TaskResult struct {
TaskId string `json:"task_id" gorm:"type:varchar(36)"`
Client string `json:"client" gorm:"type:varchar(36)"`
Result string `json:"result" gorm:"type:longtext"`
ExitCode *int `json:"exit_code" gorm:"type:int"`
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp"`
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp"`
}
+41 -33
View File
@@ -6,6 +6,7 @@ import (
"gorm.io/gorm"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
@@ -100,7 +101,9 @@ func CompactRecord() error {
continue
}
}
if flags.DatabaseType != "sqlite" {
return nil // Only vacuum for SQLite
}
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming the database: %v", err)
}
@@ -176,46 +179,46 @@ func processTimeRange(db *gorm.DB, clientUUID string, start, end time.Time, inte
count := len(agg.Records)
for _, r := range agg.Records {
sumCPU += r.CPU
sumGPU += r.GPU
sumLOAD += r.LOAD
sumTEMP += r.TEMP
sumRAM += r.RAM
sumRAMTotal += r.RAMTotal
sumSWAP += r.SWAP
sumSWAPTotal += r.SWAPTotal
sumDISK += r.DISK
sumDISKTotal += r.DISKTotal
sumNETIn += r.NETIn
sumNETOut += r.NETOut
sumNETTotalUp += r.NETTotalUp
sumNETTotalDown += r.NETTotalDown
sumPROCESS += r.PROCESS
sumCPU += r.Cpu
sumGPU += r.Gpu
sumLOAD += r.Load
sumTEMP += r.Temp
sumRAM += r.Ram
sumRAMTotal += r.RamTotal
sumSWAP += r.Swap
sumSWAPTotal += r.SwapTotal
sumDISK += r.Disk
sumDISKTotal += r.DiskTotal
sumNETIn += r.NetIn
sumNETOut += r.NetOut
sumNETTotalUp += r.NetTotalUp
sumNETTotalDown += r.NetTotalDown
sumPROCESS += r.Process
sumConnections += r.Connections
sumConnectionsUDP += r.ConnectionsUDP
sumConnectionsUDP += r.ConnectionsUdp
}
// Create new aggregated record
newRecord := models.Record{
Client: clientUUID,
Time: agg.StartTime,
CPU: sumCPU / float32(count),
GPU: sumGPU / float32(count),
RAM: sumRAM / int64(count),
RAMTotal: sumRAMTotal / int64(count),
SWAP: sumSWAP / int64(count),
SWAPTotal: sumSWAPTotal / int64(count),
LOAD: sumLOAD / float32(count),
TEMP: sumTEMP / float32(count),
DISK: sumDISK / int64(count),
DISKTotal: sumDISKTotal / int64(count),
NETIn: sumNETIn / int64(count),
NETOut: sumNETOut / int64(count),
NETTotalUp: sumNETTotalUp / int64(count),
NETTotalDown: sumNETTotalDown / int64(count),
PROCESS: sumPROCESS / count,
Cpu: sumCPU / float32(count),
Gpu: sumGPU / float32(count),
Ram: sumRAM / int64(count),
RamTotal: sumRAMTotal / int64(count),
Swap: sumSWAP / int64(count),
SwapTotal: sumSWAPTotal / int64(count),
Load: sumLOAD / float32(count),
Temp: sumTEMP / float32(count),
Disk: sumDISK / int64(count),
DiskTotal: sumDISKTotal / int64(count),
NetIn: sumNETIn / int64(count),
NetOut: sumNETOut / int64(count),
NetTotalUp: sumNETTotalUp / int64(count),
NetTotalDown: sumNETTotalDown / int64(count),
Process: sumPROCESS / count,
Connections: sumConnections / count,
ConnectionsUDP: sumConnectionsUDP / count,
ConnectionsUdp: sumConnectionsUDP / count,
}
if err := tx.Create(&newRecord).Error; err != nil {
@@ -226,3 +229,8 @@ func processTimeRange(db *gorm.DB, clientUUID string, start, end time.Time, inte
return tx.Commit().Error
}
func Save(rec models.Record) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
}
+99
View File
@@ -0,0 +1,99 @@
package tasks
import (
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func CreateTask(taskId string, clients []string, command string) error {
db := dbcore.GetDBInstance()
// Create a new task in the database with clients as JSON array
task := models.Task{
TaskId: taskId,
Clients: models.StringArray(clients),
Command: command,
}
if err := db.Create(&task).Error; err != nil {
return err
}
var taskResults []models.TaskResult
for _, client := range clients {
taskResults = append(taskResults, models.TaskResult{
TaskId: taskId,
Client: client,
Result: "",
ExitCode: nil,
FinishedAt: nil,
CreatedAt: time.Now(),
})
}
if len(taskResults) > 0 {
return db.Create(&taskResults).Error
}
return nil
}
func GetTaskByTaskId(taskId string) (*models.Task, error) {
var task models.Task
if err := dbcore.GetDBInstance().Where("task_id = ?", taskId).First(&task).Error; err != nil {
return nil, err
}
return &task, nil
}
func GetTasksByClientId(clientId string) ([]models.Task, error) {
var tasks []models.Task
if err := dbcore.GetDBInstance().Where("clients LIKE ?", "%"+clientId+"%").Find(&tasks).Error; err != nil {
return nil, err
}
return tasks, nil
}
func GetSpecificTaskResult(taskId, clientId string) (*models.TaskResult, error) {
var result models.TaskResult
if err := dbcore.GetDBInstance().Where("task_id = ? AND client = ?", taskId, clientId).First(&result).Error; err != nil {
return nil, err
}
return &result, nil
}
func GetAllTasksResultByUUID(uuid string) ([]models.TaskResult, error) {
var results []models.TaskResult
if err := dbcore.GetDBInstance().Where("client = ?", uuid).Find(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func GetAllTasks() ([]models.Task, error) {
var tasks []models.Task
if err := dbcore.GetDBInstance().Find(&tasks).Error; err != nil {
return nil, err
}
return tasks, nil
}
func GetTaskResultsByTaskId(taskId string) ([]models.TaskResult, error) {
var results []models.TaskResult
if err := dbcore.GetDBInstance().Where("task_id = ?", taskId).Find(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func DeleteTaskByTaskId(taskId string) error {
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
}
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
return dbcore.GetDBInstance().
Model(&models.TaskResult{}).
Where("task_id = ? AND client = ?", taskId, clientId).
Updates(map[string]interface{}{
"result": result,
"exit_code": exitCode,
"finished_at": timestamp,
}).Error
}
func ClearTaskResultsByTimeBefore(before time.Time) error {
return dbcore.GetDBInstance().Where("created_at < ?", before.Format(time.RFC3339)).Delete(&models.TaskResult{}).Error
}
+10 -4
View File
@@ -3,15 +3,16 @@ module github.com/komari-monitor/komari
go 1.23.2
require (
github.com/gin-contrib/cors v1.7.5
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/oschwald/maxminddb-golang v1.13.1
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
gorm.io/gorm v1.26.1
)
@@ -24,6 +25,8 @@ require golang.org/x/crypto v0.37.0 // indirect
require golang.org/x/net v0.39.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
@@ -33,6 +36,7 @@ require (
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/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -40,7 +44,7 @@ require (
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/text v0.2.0 // indirect
github.com/kr/pretty v0.3.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
@@ -48,12 +52,14 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+24 -6
View File
@@ -1,3 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
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=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -13,8 +17,6 @@ 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/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/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
@@ -27,6 +29,9 @@ 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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -48,8 +53,12 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -67,8 +76,12 @@ github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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=
@@ -101,18 +114,23 @@ golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.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.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
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=
+4
View File
@@ -1,9 +1,13 @@
package main
import (
"log"
"github.com/komari-monitor/komari/cmd"
"github.com/komari-monitor/komari/utils"
)
func main() {
log.Printf("Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
cmd.Execute()
}
+26 -3
View File
@@ -8,6 +8,7 @@ import (
"io/fs"
"log"
"net/http"
"os" // added for file existence check
"strings"
"github.com/gin-gonic/gin"
@@ -41,17 +42,20 @@ func initIndex() {
}()
index, err := io.ReadAll(indexFile)
if err != nil {
log.Fatalln("Failed to read index.html:", err)
log.Println("Failed to read index.html:", err)
}
RawIndexFile = string(index)
}
func UpdateIndex(cfg models.Config) {
replaceMap := map[string]string{
"<!-- customize head -->": cfg.CustomHead,
"</head>": cfg.CustomHead + "</head>",
"</body>": cfg.CustomBody + "</body>",
}
updated := RawIndexFile
for k, v := range replaceMap {
IndexFile = strings.Replace(RawIndexFile, k, v, 1)
updated = strings.Replace(updated, k, v, 1)
}
IndexFile = updated
}
func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
@@ -71,6 +75,25 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
}
r.StaticFS(fmt.Sprintf("/%s/", folders[i]), http.FS(sub))
}
// Serve favicon.ico: use local file if exists, fallback to embedded
r.GET("/favicon.ico", func(c *gin.Context) {
if _, err := os.Stat("data/favicon.ico"); err == nil {
c.File("data/favicon.ico")
return
}
f, err := DistFS.Open("favicon.ico")
if err != nil {
c.Status(http.StatusNotFound)
return
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Data(http.StatusOK, "image/x-icon", data)
})
noRoute(func(c *gin.Context) {
if c.Request.Method != "GET" && c.Request.Method != "POST" {
+4 -4
View File
@@ -4,11 +4,11 @@
- [ ] 低带宽模式
- [ ] Web终端
- [x] Web终端
- [ ] 一键部署脚本
- [x] 一键部署脚本
- [ ] 远程命令执行
- [x] 远程命令执行
## Serser
@@ -20,7 +20,7 @@
- [x] 命令行指令 chpasswd 强制更换密码
- [ ] Github单点登录、绑定、解绑
- [x] Github单点登录、绑定、解绑
- [ ] 2FA
+1 -1
View File
@@ -15,7 +15,7 @@ import (
)
var (
GeoIpUrl = "https://gh-proxy.com/raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
GeoIpUrl = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
GeoIpFilePath = "./data/GeoLite2-Country.mmdb"
geoIpDb *maxminddb.Reader = nil
lock = &sync.RWMutex{}
+95
View File
@@ -0,0 +1,95 @@
package oauth
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils"
)
type GitHubUser struct {
ID int `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
}
// 获取OAuth的URL、state !!请务必验证state防止CSRF攻击
func GetOAuthUrl() (string, string) {
cfg, _ := config.Get()
state := utils.GenerateRandomString(16)
// 构建GitHub OAuth授权URL
authURL := fmt.Sprintf(
"https://github.com/login/oauth/authorize?client_id=%s&state=%s&scope=user:email",
url.QueryEscape(cfg.OAuthClientID),
url.QueryEscape(state),
)
return authURL, state
}
func OAuthCallback(code, state string) (GitHubUser, error) {
cfg, _ := config.Get()
// 验证state防止CSRF攻击
// state, _ := c.Cookie("oauth_state")
if state == "" {
return GitHubUser{}, fmt.Errorf("invalid state")
}
// 获取code
//code := c.Query("code")
if code == "" {
return GitHubUser{}, fmt.Errorf("no code provided")
}
// 获取访问令牌
tokenURL := "https://github.com/login/oauth/access_token"
data := url.Values{
"client_id": {cfg.OAuthClientID},
"client_secret": {cfg.OAuthClientSecret},
"code": {code},
}
req, _ := http.NewRequest("POST", tokenURL, nil)
req.URL.RawQuery = data.Encode()
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return GitHubUser{}, fmt.Errorf("failed to get access token: %v", err)
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return GitHubUser{}, fmt.Errorf("failed to parse access token response: %v", err)
}
// 获取用户信息
userReq, _ := http.NewRequest("GET", "https://api.github.com/user", nil)
userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
userReq.Header.Set("Accept", "application/json")
userResp, err := http.DefaultClient.Do(userReq)
if err != nil {
return GitHubUser{}, fmt.Errorf("failed to get user info: %v", err)
}
defer userResp.Body.Close()
var githubUser GitHubUser
if err := json.NewDecoder(userResp.Body).Decode(&githubUser); err != nil {
return GitHubUser{}, fmt.Errorf("failed to parse user info response: %v", err)
}
return githubUser, nil
}
+67
View File
@@ -0,0 +1,67 @@
package telegram
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/komari-monitor/komari/database/config"
)
func SendMessage(message, msgType string) error {
conf, err := config.Get()
if err != nil {
return err
}
if !conf.TelegramEnabled || message == "" {
return errors.New("telegram is disabled or message is empty")
}
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
data := url.Values{}
data.Set("chat_id", conf.TelegramChatID)
data.Set("text", message)
if msgType != "text" {
data.Set("parse_mode", msgType)
}
resp, err := http.PostForm(endpoint, data)
if err != nil {
return fmt.Errorf("failed to send message: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram API returned non-OK status: %d", resp.StatusCode)
}
var result struct {
Ok bool `json:"ok"`
Description string `json:"description"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if !result.Ok {
return fmt.Errorf("telegram API error: %s", result.Description)
}
return nil
}
// SendTextMessage sends a text message via Telegram API
func SendTextMessage(message string) error {
return SendMessage(message, "text")
}
// SendMarkdownMessage sends a message formatted in Markdown via Telegram API
func SendMarkdownMessage(message string) error {
return SendMessage(message, "MarkdownV2")
}
func SendHTMLMessage(message string) error {
return SendMessage(message, "HTML")
}
+59
View File
@@ -0,0 +1,59 @@
package utils
import (
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/models"
)
func AverageReport(uuid string, time time.Time, records []common.Report) models.Record {
var sumCPU, sumGPU, sumLOAD, sumTEMP float32
var sumRAM, sumRAMTotal, sumSWAP, sumSWAPTotal, sumDISK, sumDISKTotal int64
var sumNETIn, sumNETOut, sumNETTotalUp, sumNETTotalDown int64
var sumPROCESS, sumConnections, sumConnectionsUDP int
count := len(records)
if count == 0 {
return models.Record{}
}
for _, r := range records {
sumCPU += float32(r.CPU.Usage)
sumGPU += 0
sumLOAD += float32(r.Load.Load1)
sumTEMP += 0
sumRAM += r.Ram.Used
sumSWAP += r.Swap.Used
sumDISK += r.Disk.Used
sumNETIn += r.Network.Down
sumNETOut += r.Network.Up
sumNETTotalUp += r.Network.TotalUp
sumNETTotalDown += r.Network.TotalDown
sumPROCESS += r.Process
sumConnections += r.Connections.TCP
sumConnectionsUDP += r.Connections.UDP
}
// Create new aggregated record
newRecord := models.Record{
Client: uuid,
Time: time,
Cpu: sumCPU / float32(count),
Gpu: sumGPU / float32(count),
Ram: sumRAM / int64(count),
RamTotal: sumRAMTotal / int64(count),
Swap: sumSWAP / int64(count),
SwapTotal: sumSWAPTotal / int64(count),
Load: sumLOAD / float32(count),
Temp: sumTEMP / float32(count),
Disk: sumDISK / int64(count),
DiskTotal: sumDISKTotal / int64(count),
NetIn: sumNETIn / int64(count),
NetOut: sumNETOut / int64(count),
NetTotalUp: sumNETTotalUp / int64(count),
NetTotalDown: sumNETTotalDown / int64(count),
Process: sumPROCESS / count,
Connections: sumConnections / count,
ConnectionsUdp: sumConnectionsUDP / count,
}
return newRecord
}
+6
View File
@@ -0,0 +1,6 @@
package utils
var (
CurrentVersion = "0.0.1"
VersionHash = "unknown"
)