Compare commits

..
46 Commits
Author SHA1 Message Date
Akizon 3e415da8d4 Merge pull request #114 from komari-monitor/dev
feat: admin页面不使用自定义样式
2025-07-12 18:42:05 +08:00
Akizon77 a125466b75 feat: admin页面不使用自定义样式 2025-07-12 18:39:04 +08:00
Akizon a9306cd651 Merge pull request #110 from komari-monitor/dev
feat: 添加客户端分组字段
2025-07-09 14:47:21 +08:00
Akizon77 6af72f41c0 feat: 添加客户端分组字段 2025-07-09 14:19:56 +08:00
Akizon77 e9e04593c7 feat: 负载通知 2025-07-08 23:31:20 +08:00
Akizon 43b39c9f68 Merge pull request #109 from komari-monitor/dev
refactor: 重建数据库关系
2025-07-08 16:58:28 +08:00
Akizon77 8d5ce55301 refactor: 离线通知不回传ClientInfo 2025-07-08 16:57:23 +08:00
Akizon77 e72d461869 refactor: 重建数据库关系 2025-07-08 16:52:21 +08:00
Akizon 5cc4b518ba Merge pull request #106 from komari-monitor/dev
feat: 管理脚本
2025-07-06 00:49:32 +08:00
Akizon77 8aed055cc4 feat: 精简安装脚本 2025-07-06 00:48:44 +08:00
Akizon77 a306b6911a feat: 显示默认登录信息 2025-07-05 23:29:59 +08:00
Akizon77 776dfee23b feat: 移除日志配置前缀颜色 2025-07-05 23:26:44 +08:00
Akizon77 02e01a1d7c feat: 修正二进制文件命名格式 2025-07-05 23:22:26 +08:00
Akizon77 6b0b5b4855 feat: 添加安装脚本 2025-07-05 23:17:46 +08:00
Akizon 9dd09731bd Merge pull request #100 from komari-monitor/dev
feat: 补全相关日志记录
2025-07-04 00:47:00 +08:00
Akizon77 963c829164 feat: 添加重试机制以增强消息发送的可靠性 2025-07-04 00:45:43 +08:00
Akizon77 d01fafbf0f feat: 更新终端相关日志记录 2025-07-04 00:42:50 +08:00
Akizon77 729db05346 feat: 增强cloudflared运行时日志记录,添加错误处理和成功退出日志 2025-07-04 00:35:25 +08:00
Akizon 70dc06a761 Merge pull request #90 from komari-monitor/dev
docs: 更新 Agent 开发指南
2025-07-01 22:40:38 +08:00
Akizon77 be059b7aa3 feat: 更新 Agent 开发指南 2025-07-01 22:39:36 +08:00
Akizon b80c917f25 Merge pull request #89 from komari-monitor/dev
fix: 数据库可能两次打包在压缩包内
2025-07-01 17:09:42 +08:00
Akizon77 6b74bf90ce fix: 数据库可能两次打包在压缩包内 2025-07-01 17:09:11 +08:00
Akizon a2e4665ff5 Merge pull request #88 from komari-monitor/dev
feat: 优化报告平均值计算
2025-07-01 16:57:04 +08:00
Akizon77 2c9bd9c847 feat: 修改AverageReport函数以支持topPercentage参数,优化报告平均值计算 2025-07-01 16:56:31 +08:00
Akizon77 54eb957618 feat: 为GeoIP服务提供者添加名称方法 2025-07-01 16:44:56 +08:00
Akizon 370f0ff815 Merge pull request #87 from komari-monitor/dev
feat: #68 集成cloudflared
2025-07-01 15:45:15 +08:00
Akizon77 9bd9a280e5 feat: #68 集成cloudflared 2025-07-01 15:34:00 +08:00
Akizon daf6ae291f Merge pull request #86 from komari-monitor/dev
feat: 添加IP查询缓存机制
2025-07-01 15:03:06 +08:00
Akizon77 3d774eec81 feat: 添加IP查询缓存机制 2025-07-01 14:58:56 +08:00
Akizon77 129e0be151 refactor: 简化GeoIP相关调用,可以插中间件了 2025-07-01 14:51:17 +08:00
Akizon 862657ea86 Merge pull request #83 from komari-monitor/dev
feat: 在获取的任务中添加间隔时间字段
2025-06-30 18:38:06 +08:00
Akizon77 70b519a345 feat: 在获取的任务中添加间隔时间字段 2025-06-30 18:09:31 +08:00
Akizon 6600e4014d Merge pull request #82 from komari-monitor/dev
feat: #53 通知方式兼容SMTP邮件、支持下载备份
2025-06-29 23:50:00 +08:00
Akizon77 b8a6f18a77 feat: 支持下载备份 2025-06-29 23:29:19 +08:00
Akizon77 03e08cdfbb fix: 只有在通知方式变动时重载MessageSenderProvider 2025-06-29 22:30:28 +08:00
Akizon77 b07f7345ee feat: 通知方式兼容SMTP邮件 2025-06-29 22:28:04 +08:00
Akizon77 e85996178a refactor: 抽象消息发送接口 2025-06-29 22:17:35 +08:00
Akizon77 5547fcd067 ci: 发布main分支的docker镜像,修正release 2025-06-29 21:54:41 +08:00
Akizon aef4344997 Merge pull request #81 from komari-monitor/dev
fix: #79 GeoIP无法更新
feat: IP查询兼容 ipinfo.io
2025-06-29 21:44:43 +08:00
Akizon77 061197cd77 ci: 发布到测试环境不显示部分日志 2025-06-29 21:40:14 +08:00
Akizon77 c00d1d0cb2 ci: 发布docker镜像时预编译二进制 2025-06-29 21:36:30 +08:00
Akizon77 b6a7511e33 fix: #79 GeoIP无法更新 2025-06-29 01:31:54 +08:00
Akizon77 13450a50eb feat: IP查询兼容 ipinfo.io 2025-06-28 21:04:37 +08:00
Akizon eb883ccc6f Merge pull request #78 from komari-monitor/dev
feat: 到期提醒显示具体还剩多少天
2025-06-28 20:20:43 +08:00
Akizon77 2ae9b3516c fix: MaxMind 数据库 initialize 方法需要在解锁后调用,以避免死锁 2025-06-28 20:15:31 +08:00
Akizon77 7dc2a0294e feat: 到期提醒显示具体还剩多少天 2025-06-28 20:02:32 +08:00
45 changed files with 1790 additions and 268 deletions

No files matched your search

+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- name: Add host to known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy to production
run: |
+51 -5
View File
@@ -1,4 +1,4 @@
name: Docker Build and Publish
name: Publish Docker Image
on:
push:
@@ -11,7 +11,7 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
build-and-push-multi-arch:
runs-on: ubuntu-latest
permissions:
contents: read
@@ -21,6 +21,54 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig
run: |
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build static binaries for multi-arch
env:
GOOS: linux
CGO_ENABLED: 1
GIN_MODE: release
run: |
VERSION="${{ github.ref_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
@@ -42,8 +90,6 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
VERSION_HASH=${{ github.sha }}
+48 -7
View File
@@ -13,14 +13,59 @@ env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-docker:
jobs:
build-and-push-multi-arch:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig
run: |
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build static binaries for multi-arch
env:
GOOS: linux
CGO_ENABLED: 1
GIN_MODE: release
run: |
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -48,10 +93,6 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
build-args: |
VERSION=${{ github.event.release.tag_name }}
VERSION_HASH=${{ github.sha }}
+9 -28
View File
@@ -1,34 +1,16 @@
FROM node:23-alpine AS frontend
WORKDIR /web
RUN apk add --no-cache git
RUN git clone https://github.com/komari-monitor/komari-web .
RUN npm install
RUN npm run build
FROM golang:1.24 AS builder
WORKDIR /app
# RUN apt-get update && apt-get install -y musl-tools
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /web/dist ./public/dist
ENV CGO_ENABLED=1
ARG VERSION=unknown
ARG VERSION_HASH=unknown
# RUN CC=musl-gcc go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static" -o komari .
RUN go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o komari .
# FROM scratch
FROM alpine:3.21
WORKDIR /app
COPY --from=builder /app/komari .
# Docker buildx 会在构建时自动填充这些变量
ARG TARGETOS
ARG TARGETARCH
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN chmod +x /app/komari
ENV GIN_MODE=release
# 数据库配置环境变量(可以在运行时覆盖)
ENV KOMARI_DB_TYPE=sqlite
ENV KOMARI_DB_FILE=/app/data/komari.db
ENV KOMARI_DB_HOST=localhost
@@ -40,5 +22,4 @@ ENV KOMARI_LISTEN=0.0.0.0:25774
EXPOSE 25774
# 使用环境变量启动服务
CMD ["/app/komari","server"]
CMD ["/app/komari", "server"]
+3 -43
View File
@@ -78,7 +78,7 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
这个坑晚点再填吧 ヽ( ̄ω ̄( ̄ω ̄〃)ゝ
## 客户端 Agent 开发指南
这个坑晚点再填吧 (o゜▽゜)o☆
[Komari Agent 信息上报与事件处理文档](https://komari-monitor.github.io/komari-document/dev/agent.html)
## 贡献
欢迎提交 Issue 或 Pull Request!
@@ -87,46 +87,6 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
- 感谢我自己能这么闲
- 广告位招租喵
## 引用
- [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.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+6 -5
View File
@@ -23,10 +23,11 @@ var (
)
type TerminalSession struct {
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
RequesterIp string
}
var TerminalSessionsMutex = &sync.Mutex{}
@@ -61,7 +62,7 @@ func SaveClientReportToDB() error {
// 计算平均报告并添加到记录列表
if len(filtered) > 0 {
r := utils.AverageReport(uuid, time.Now(), filtered)
r := utils.AverageReport(uuid, time.Now(), filtered, 0.3)
records = append(records, r)
}
}
+139
View File
@@ -0,0 +1,139 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
)
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
func DownloadBackup(c *gin.Context) {
backupFileName := fmt.Sprintf("backup-%s.zip", time.Now().Format("20060102-150405"))
c.Writer.Header().Set("Content-Type", "application/zip")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
zipWriter := zip.NewWriter(c.Writer)
defer zipWriter.Close()
err := filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 获取在 zip 包内的相对路径
relPath, err := filepath.Rel("./data", filePath)
if err != nil {
return err
}
// 跳过根目录本身,只打包内容
if relPath == "." {
return nil
}
// zip 内路径统一用正斜杠
zipPath := filepath.ToSlash(relPath)
if info.IsDir() {
// 在 zip 中创建目录项
_, err = zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath + "/",
Method: zip.Deflate, // zip.Store 不压缩
Modified: info.ModTime(),
})
return err
}
// 普通文件,写入 zip
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath,
Method: zip.Deflate,
Modified: info.ModTime(),
})
if err != nil {
return err
}
_, err = io.Copy(writer, file)
return err
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving data folder: %v", err))
return
}
// 获取 ./data 和数据库文件的绝对路径以进行可靠的比较
dataAbsPath, err := filepath.Abs("./data")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error getting absolute path for data directory: %v", err))
return
}
dbFilePath := flags.DatabaseFile
dbAbsPath, err := filepath.Abs(dbFilePath)
if err != nil {
// 如果无法解析数据库路径,可能是一个配置问题,但我们记录并跳过,避免备份失败
log.Printf("Could not determine absolute path for database file '%s', skipping explicit addition. Error: %v\n", dbFilePath, err)
return
}
// 如果数据库文件的绝对路径不是以 ./data 目录的绝对路径开头,则单独添加它
if !strings.HasPrefix(dbAbsPath, dataAbsPath) {
dbFileName := filepath.Base(dbFilePath)
dbInfo, err := os.Stat(dbFilePath)
if err != nil {
if os.IsNotExist(err) {
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
return // 数据库不存在是正常情况,直接返回
}
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
if !dbInfo.IsDir() {
file, err := os.Open(dbFilePath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening database file: %v", err))
return
}
defer file.Close()
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: dbFileName,
Method: zip.Deflate,
Modified: dbInfo.ModTime(),
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating zip entry for database file: %v", err))
return
}
_, err = io.Copy(writer, file)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing database file to zip: %v", err))
return
}
}
} else {
log.Printf("Database file '%s' is within './data', skipping explicit addition.\n", dbFilePath)
}
// zipWriter.Close() 由 defer 自动调用
}
-1
View File
@@ -1 +0,0 @@
package notification
+90
View File
@@ -0,0 +1,90 @@
package notification
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
)
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
func AddLoadNotification(c *gin.Context) {
var req struct {
Clients []string `json:"clients" binding:"required"`
Name string `json:"name"`
Metric string `json:"metric" binding:"required"`
Threshold float32 `json:"threshold" binding:"required"` // 阈值百分比
Ratio float32 `json:"ratio" binding:"required"` // 达标时间比
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if req.Interval > 4*60 || req.Interval <= 0 {
api.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
return
}
if req.Ratio <= 0 || req.Ratio > 1 {
api.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
return
}
if taskID, err := notification.AddLoadNotification(req.Clients, req.Name, req.Metric, req.Threshold, req.Ratio, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
// POST body: id []uint
func DeleteLoadNotification(c *gin.Context) {
var req struct {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := notification.DeleteLoadNotification(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
}
}
// POST body: id []uint, updates map[string]interface{}
func EditLoadNotification(c *gin.Context) {
var req struct {
Notifications []*models.LoadNotification `json:"notifications" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := notification.EditLoadNotification(req.Notifications); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, notification := range req.Notifications {
// notification.DeleteLoadNotification([]uint{notification.Id})
// }
api.RespondSuccess(c, nil)
}
}
func GetAllLoadNotifications(c *gin.Context) {
notifications, err := notification.GetAllLoadNotifications()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, notifications)
}
+2 -2
View File
@@ -12,7 +12,7 @@ import (
func TestSendMessage(c *gin.Context) {
message := "Komari test message."
err := messageSender.SendTextMessage(message)
err := messageSender.SendTextMessage(message, "Komari Test Notification")
if err != nil {
api.RespondError(c, 500, "Failed to send message: "+err.Error())
return
@@ -38,7 +38,7 @@ func TestGeoIp(c *gin.Context) {
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.CurrentProvider.GetGeoInfo(net.ParseIP(ip))
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
if err != nil {
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.CurrentProvider.UpdateDatabase(); err != nil {
if err := geoip.UpdateDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
+2 -2
View File
@@ -29,13 +29,13 @@ func UploadBasicInfo(c *gin.Context) {
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.CurrentProvider.GetGeoInfo(ip4)
ip4_record, _ := geoip.GetGeoInfo(ip4)
if ip4_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
}
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
ip6 := net.ParseIP(ipv6)
ip6_record, _ := geoip.CurrentProvider.GetGeoInfo(ip6)
ip6_record, _ := geoip.GetGeoInfo(ip6)
if ip6_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
}
+3 -2
View File
@@ -97,8 +97,9 @@ func GetPingRecords(c *gin.Context) {
// 只返回有记录的任务
if _, ok := taskIdSet[t.Id]; ok {
tasksList = append(tasksList, gin.H{
"id": t.Id,
"name": t.Name,
"id": t.Id,
"name": t.Name,
"interval": t.Interval,
})
}
}
+8 -7
View File
@@ -41,10 +41,11 @@ func RequestTerminal(c *gin.Context) {
// 新建一个终端连接
id := utils.GenerateRandomString(32)
session := &TerminalSession{
UserUUID: user_uuid.(string),
UUID: uuid,
Browser: conn,
Agent: nil,
UserUUID: user_uuid.(string),
UUID: uuid,
Browser: conn,
Agent: nil,
RequesterIp: c.ClientIP(),
}
TerminalSessionsMutex.Lock()
@@ -95,7 +96,7 @@ func RequestTerminal(c *gin.Context) {
}
TerminalSessionsMutex.Unlock()
})
logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
//logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
}
func ForwardTerminal(id string) {
@@ -104,7 +105,7 @@ func ForwardTerminal(id string) {
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
return
}
logOperation.Log("", session.UserUUID, "established, terminal id:"+id, "terminal")
logOperation.Log(session.RequesterIp, session.UserUUID, "established, terminal id:"+id, "terminal")
established_time := time.Now()
errChan := make(chan error, 1)
@@ -161,7 +162,7 @@ func ForwardTerminal(id string) {
session.Browser.Close()
}
disconnect_time := time.Now()
logOperation.Log("", session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
logOperation.Log(session.RequesterIp, session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
+8 -8
View File
@@ -9,7 +9,7 @@ import (
"github.com/spf13/cobra"
)
func getEnv(key, defaultValue string) string {
func GetEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
@@ -19,13 +19,13 @@ func getEnv(key, defaultValue string) string {
// 从环境变量获取默认值
var (
dbTypeEnv = getEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = getEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = getEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = getEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = getEnv("KOMARI_DB_USER", "root")
dbPassEnv = getEnv("KOMARI_DB_PASS", "")
dbNameEnv = getEnv("KOMARI_DB_NAME", "komari")
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
)
var RootCmd = &cobra.Command{
+27 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -25,11 +26,14 @@ import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
u_notification "github.com/komari-monitor/komari/utils/notification"
"github.com/komari-monitor/komari/ws"
"github.com/spf13/cobra"
@@ -54,6 +58,14 @@ var ServerCmd = &cobra.Command{
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
if strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
if err != nil {
log.Fatalf("Failed to run cloudflared: %v", err)
}
}
r := gin.Default()
@@ -68,6 +80,9 @@ var ServerCmd = &cobra.Command{
if event.New.GeoIpProvider != event.Old.GeoIpProvider {
go geoip.InitGeoIp()
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
go messageSender.Initialize()
}
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
@@ -121,6 +136,7 @@ var ServerCmd = &cobra.Command{
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
@@ -206,6 +222,13 @@ var ServerCmd = &cobra.Command{
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
loadAlertGroup := notificationGroup.Group("/load")
{
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
loadAlertGroup.POST("/add", notification.AddLoadNotification)
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
}
}
pingTaskGroup := adminAuthrized.Group("/ping")
@@ -253,7 +276,7 @@ var ServerCmd = &cobra.Command{
func init() {
// 从环境变量获取监听地址
listenAddr := getEnv("KOMARI_LISTEN", "0.0.0.0:25774")
listenAddr := GetEnv("KOMARI_LISTEN", "0.0.0.0:25774")
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
RootCmd.AddCommand(ServerCmd)
}
@@ -284,6 +307,7 @@ func InitDatabase() {
// #region 定时任务
func DoScheduledWork() {
tasks.ReloadPingSchedule()
d_notification.ReloadLoadNotificationSchedule()
ticker := time.NewTicker(time.Minute * 30)
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
@@ -311,8 +335,10 @@ func DoScheduledWork() {
func OnShutdown() {
logOperation.Log("", "", "server is shutting down", "info")
cloudflared.Kill()
}
func OnFatal(err error) {
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
cloudflared.Kill()
}
+12 -1
View File
@@ -22,7 +22,10 @@ func Get() (models.Config, error) {
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "mmdb",
GeoIpProvider: "ip-api",
EmailPort: 587,
EmailUseSSL: true,
EmailReceiver: "", // Default empty
UpdatedAt: models.FromTime(time.Now()),
CreatedAt: models.FromTime(time.Now()),
}
@@ -53,6 +56,14 @@ func Save(cst models.Config) error {
"o_auth_client_secret",
"o_auth_enabled",
"custom_head",
"email_enabled",
"email_host",
"email_port",
"email_username",
"email_password",
"email_sender",
"email_use_ssl",
"email_receiver",
"updated_at").
Updates(cst).Error; err != nil {
return err
+4
View File
@@ -75,6 +75,10 @@ func MergeDatabase(db *gorm.DB) {
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
}
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
db.Migrator().DropTable(&models.LoadNotification{})
}
}
var (
+1
View File
@@ -140,6 +140,7 @@ func GetAppLocation() *time.Location {
appLocation = defaultLocation
log.Printf("Info: Environment variable TZ not set, using default UTC timezone (%s).\n", appLocation.String())
}
time.Local = appLocation // 设置全局时区
})
return appLocation
}
+8
View File
@@ -21,6 +21,14 @@ type Config struct {
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
// Email 配置
EmailHost string `json:"email_host" gorm:"type:varchar(255)"` // 邮件服务器地址
EmailPort int `json:"email_port" gorm:"default:587"` // 邮件服务器端口,默认587
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"` // 邮箱用户名
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"` // 邮箱密码
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"` // 邮箱发送者地址
EmailUseSSL bool `json:"email_use_ssl" gorm:"default:true"` // 是否使用 SSL/TLS 连接,默认 true
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"` // 邮件接收者地址
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
+13 -11
View File
@@ -31,26 +31,28 @@ type Client struct {
BillingCycle int `json:"billing_cycle"`
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt UTCTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
}
// User represents an authenticated user
type User struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Username string `json:"username" gorm:"type:varchar(50);unique;not null"`
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Username string `json:"username" gorm:"type:varchar(50);unique;not null"`
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
}
// Session manages user sessions
type Session struct {
UUID string `json:"uuid" gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
Session string `json:"session" gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
UUID string `json:"uuid" gorm:"type:varchar(36)"`
Session string `json:"session" gorm:"type:varchar(255);primaryKey;uniqueIndex:idx_sessions_session;not null"`
UserAgent string `json:"user_agent" gorm:"type:text"`
Ip string `json:"ip" gorm:"type:varchar(100)"`
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
@@ -63,7 +65,7 @@ type Session struct {
// Record logs client metrics over time
type Record struct {
Client string `json:"client" gorm:"type:varchar(36);index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Client string `json:"client" gorm:"type:varchar(36);index"`
Time UTCTime `json:"time" gorm:"index"`
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
+11 -10
View File
@@ -2,8 +2,9 @@ package models
// Notification 定义了通知相关的数据库模型
type OfflineNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified UTCTime `json:"last_notified"` // 上次通知时间
@@ -11,12 +12,12 @@ type OfflineNotification struct {
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
type LoadNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Window int `json:"window" gorm:"type:int;not null;default:300"` // 观测窗口(秒)
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒)
LastNotified UTCTime `json:"last_notified"` // 上次通知时间
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"type:varchar(255)"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
LastNotified UTCTime `json:"last_notified"` // 上次通知时间
}
+6 -4
View File
@@ -1,10 +1,12 @@
package models
type PingRecord struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
TaskId uint `json:"task_id" gorm:"not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:TaskId;references:Id"`
Time UTCTime `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
TaskId uint `json:"task_id" gorm:"not null;index"`
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
Time UTCTime `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
}
type PingTask struct {
+7 -4
View File
@@ -1,13 +1,16 @@
package models
type Task struct {
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Command string `json:"command" gorm:"type:text"`
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Command string `json:"command" gorm:"type:text"`
Results []TaskResult `gorm:"foreignKey:TaskId;references:TaskId;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
}
type TaskResult struct {
TaskId string `json:"task_id" gorm:"type:varchar(36)"`
TaskId string `json:"task_id" gorm:"type:varchar(36);index"`
Client string `json:"client" gorm:"type:varchar(36)"`
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
Result string `json:"result" gorm:"type:longtext"`
ExitCode *int `json:"exit_code" gorm:"type:int"`
FinishedAt *UTCTime `json:"finished_at" gorm:"type:timestamp"`
+68
View File
@@ -0,0 +1,68 @@
package notification
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
load_notification "github.com/komari-monitor/komari/utils/notification"
"gorm.io/gorm"
)
func AddLoadNotification(clients []string, name string, metric string, threshold float32, ratio float32, interval int) (uint, error) {
db := dbcore.GetDBInstance()
notification := models.LoadNotification{
Clients: clients,
Name: name,
Metric: metric,
Threshold: threshold,
Ratio: ratio,
Interval: interval,
}
if err := db.Create(&notification).Error; err != nil {
return 0, err
}
return notification.Id, ReloadLoadNotificationSchedule()
}
func DeleteLoadNotification(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("id IN ?", id).Delete(&models.LoadNotification{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return ReloadLoadNotificationSchedule()
}
func EditLoadNotification(notifications []*models.LoadNotification) error {
db := dbcore.GetDBInstance()
for _, notification := range notifications {
result := db.Model(&models.LoadNotification{}).Where("id = ?", notification.Id).Updates(notification)
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
}
return ReloadLoadNotificationSchedule()
}
func GetAllLoadNotifications() ([]models.LoadNotification, error) {
db := dbcore.GetDBInstance()
var notifications []models.LoadNotification
if err := db.Find(&notifications).Error; err != nil {
return nil, err
}
return notifications, nil
}
func SaveLoadNotification(record models.LoadNotification) error {
db := dbcore.GetDBInstance()
return db.Create(&record).Error
}
func ReloadLoadNotificationSchedule() error {
db := dbcore.GetDBInstance()
var loadNotifications []models.LoadNotification
if err := db.Find(&loadNotifications).Error; err != nil {
return err
}
return load_notification.ReloadLoadNotificationSchedule(loadNotifications)
}
-1
View File
@@ -1 +0,0 @@
package notification
+395
View File
@@ -0,0 +1,395 @@
#!/bin/bash
# Color definitions for terminal output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "$1"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
log_step() {
echo -e "${YELLOW}$1${NC}"
}
# Global variables
INSTALL_DIR="/opt/komari"
DATA_DIR="/opt/komari/data"
SERVICE_NAME="komari"
BINARY_PATH="$INSTALL_DIR/komari"
# Show banner
show_banner() {
clear
echo "=============================================================="
echo " Komari Monitoring System Installer"
echo " https://github.com/komari-monitor/komari"
echo "=============================================================="
echo
}
# Check if running as root
check_root() {
if [ "$EUID" -ne 0 ]; then
log_error "请使用 root 权限运行此脚本"
exit 1
fi
}
# Check for systemd
check_systemd() {
if ! command -v systemctl >/dev/null 2>&1; then
return 1
else
return 0
fi
}
# Detect system architecture
detect_arch() {
local arch=$(uname -m)
case $arch in
x86_64)
echo "amd64"
;;
aarch64)
echo "arm64"
;;
i386|i686)
echo "386"
;;
riscv64)
echo "riscv64"
;;
*)
log_error "不支持的架构: $arch"
exit 1
;;
esac
}
# Check if Komari is already installed
is_installed() {
if [ -f "$BINARY_PATH" ]; then
return 0 # 0 means true in bash exit codes
else
return 1 # 1 means false
fi
}
# Install dependencies
install_dependencies() {
log_step "检查并安装依赖..."
if ! command -v curl >/dev/null 2>&1; then
if command -v apt >/dev/null 2>&1; then
log_info "使用 apt 安装依赖..."
apt update
apt install -y curl
elif command -v yum >/dev/null 2>&1; then
log_info "使用 yum 安装依赖..."
yum install -y curl
elif command -v apk >/dev/null 2>&1; then
log_info "使用 apk 安装依赖..."
apk add curl
else
log_error "未找到支持的包管理器 (apt/yum/apk)"
exit 1
fi
fi
}
# Binary installation
install_binary() {
log_step "开始二进制安装..."
if is_installed; then
log_info "Komari 已安装。要升级,请使用升级选项。"
return
fi
install_dependencies
local arch=$(detect_arch)
log_info "检测到架构: $arch"
log_step "创建安装目录: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
log_step "创建数据目录: $DATA_DIR"
mkdir -p "$DATA_DIR"
local file_name="komari-linux-${arch}"
local download_url="https://github.com/komari-monitor/komari/releases/latest/download/${file_name}"
log_step "下载 Komari 二进制文件..."
log_info "URL: $download_url"
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
log_error "下载失败"
return 1
fi
chmod +x "$BINARY_PATH"
log_success "Komari 二进制文件安装完成: $BINARY_PATH"
if ! check_systemd; then
log_step "警告:未检测到 systemd,跳过服务创建。"
log_step "您可以从命令行手动运行 Komari:"
log_step " $BINARY_PATH server -l 0.0.0.0:25774"
echo
log_success "安装完成!"
return
fi
create_systemd_service
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}.service
systemctl start ${SERVICE_NAME}.service
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
log_success "Komari 服务启动成功"
log_step "正在获取初始密码..."
sleep 5
local password=$(journalctl -u ${SERVICE_NAME} --since "1 minute ago" | grep "admin account created." | tail -n 1 | sed -e 's/.*admin account created.//')
if [ -z "$password" ]; then
log_error "未能获取初始密码,请检查日志"
fi
show_access_info "$password"
else
log_error "Komari 服务启动失败"
log_info "查看日志: journalctl -u ${SERVICE_NAME} -f"
return 1
fi
}
# Create systemd service file
create_systemd_service() {
log_step "创建 systemd 服务..."
local service_file="/etc/systemd/system/${SERVICE_NAME}.service"
cat > "$service_file" << EOF
[Unit]
Description=Komari Monitor Service
After=network.target
[Service]
Type=simple
ExecStart=${BINARY_PATH} server -l 0.0.0.0:25774
WorkingDirectory=${DATA_DIR}
Restart=always
User=root
[Install]
WantedBy=multi-user.target
EOF
log_success "systemd 服务文件创建完成"
}
# Show access information
show_access_info() {
local password=$1
echo
log_success "安装完成!"
echo
log_info "访问信息:"
log_info " URL: http://$(hostname -I | awk '{print $1}'):25774"
if [ -n "$password" ]; then
log_info "初始登录信息: $password"
fi
echo
log_info "服务管理命令:"
log_info " 状态: systemctl status $SERVICE_NAME"
log_info " 启动: systemctl start $SERVICE_NAME"
log_info " 停止: systemctl stop $SERVICE_NAME"
log_info " 重启: systemctl restart $SERVICE_NAME"
log_info " 日志: journalctl -u $SERVICE_NAME -f"
}
# Upgrade function
upgrade_komari() {
log_step "升级 Komari..."
if ! is_installed; then
log_error "Komari 未安装。请先安装它。"
return 1
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法管理服务。"
return 1
fi
log_step "停止 Komari 服务..."
systemctl stop ${SERVICE_NAME}.service
log_step "备份当前二进制文件..."
cp "$BINARY_PATH" "${BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
local arch=$(detect_arch)
local file_name="komari-linux-${arch}"
local download_url="https://github.com/komari-monitor/komari/releases/latest/download/${file_name}"
log_step "下载最新版本..."
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
log_error "下载失败,正在从备份恢复"
mv "${BINARY_PATH}.backup."* "$BINARY_PATH"
systemctl start ${SERVICE_NAME}.service
return 1
fi
chmod +x "$BINARY_PATH"
log_step "重启 Komari 服务..."
systemctl start ${SERVICE_NAME}.service
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
log_success "Komari 升级成功"
else
log_error "服务在升级后未能启动"
fi
}
# Uninstall function
uninstall_komari() {
log_step "卸载 Komari..."
if ! is_installed; then
log_info "Komari 未安装"
return 0
fi
read -p "这将删除 Komari。您确定吗?(Y/n): " confirm
if [[ $confirm =~ ^[Nn]$ ]]; then
log_info "卸载已取消"
return 0
fi
if check_systemd; then
log_step "停止并禁用服务..."
systemctl stop ${SERVICE_NAME}.service >/dev/null 2>&1
systemctl disable ${SERVICE_NAME}.service >/dev/null 2>&1
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
log_success "systemd 服务已删除"
fi
log_step "删除二进制文件..."
rm -f "$BINARY_PATH"
# 尝试在目录为空时删除该目录
rmdir "$INSTALL_DIR" 2>/dev/null || log_info "数据目录 $INSTALL_DIR 不为空,未删除"
log_success "Komari 二进制文件已删除"
log_success "Komari 卸载完成"
log_info "数据文件保留在 $DATA_DIR"
}
# Show service status
show_status() {
if ! is_installed; then
log_error "Komari 未安装"
return
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法获取服务状态。"
return
fi
log_step "Komari 服务状态:"
systemctl status ${SERVICE_NAME}.service --no-pager -l
}
# Show service logs
show_logs() {
if ! is_installed; then
log_error "Komari 未安装"
return
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法获取服务日志。"
return
fi
log_step "查看 Komari 服务日志..."
journalctl -u ${SERVICE_NAME} -f --no-pager
}
# Restart service
restart_service() {
if ! is_installed; then
log_error "Komari 未安装"
return
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法重启服务。"
return
fi
log_step "重启 Komari 服务..."
systemctl restart ${SERVICE_NAME}.service
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
log_success "服务重启成功"
else
log_error "服务重启失败"
fi
}
# Stop service
stop_service() {
if ! is_installed; then
log_error "Komari 未安装"
return
fi
if ! check_systemd; then
log_error "未检测到 systemd。无法停止服务。"
return
fi
log_step "停止 Komari 服务..."
systemctl stop ${SERVICE_NAME}.service
log_success "服务已停止"
}
# Main menu
main_menu() {
show_banner
echo "请选择操作:"
echo " 1) 安装 Komari"
echo " 2) 升级 Komari"
echo " 3) 卸载 Komari"
echo " 4) 查看状态"
echo " 5) 查看日志"
echo " 6) 重启服务"
echo " 7) 停止服务"
echo " 8) 退出"
echo
read -p "输入选项 [1-8]: " choice
case $choice in
1) install_binary ;;
2) upgrade_komari ;;
3) uninstall_komari ;;
4) show_status ;;
5) show_logs ;;
6) restart_service ;;
7) stop_service ;;
8) exit 0 ;;
*) log_error "无效选项" ;;
esac
}
# Main execution
check_root
main_menu
+6 -1
View File
@@ -113,7 +113,12 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
c.Header("Content-Type", "text/html")
c.Status(200)
c.Writer.WriteString(IndexFile)
if strings.HasPrefix(c.Request.RequestURI, "/admin") {
c.Writer.WriteString(RawIndexFile)
} else {
c.Writer.WriteString(IndexFile)
}
c.Writer.Flush()
c.Writer.WriteHeaderNow()
})
+149
View File
@@ -0,0 +1,149 @@
package cloudflared
import (
"bufio"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"runtime"
)
func downloadCloudflared(downloadURL, filePath string) error {
log.Println("downloading cloudflared...")
resp, err := http.Get(downloadURL)
if err != nil {
return err
}
defer resp.Body.Close()
out, err := os.Create(filePath)
if err != nil {
return err
}
_, err = io.Copy(out, resp.Body)
closeErr := out.Close() // 先关闭文件
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if runtime.GOOS != "windows" {
err = os.Chmod(filePath, 0755)
if err != nil {
return err
}
}
return nil
}
var cloudflaredCmd *exec.Cmd
func RunCloudflared() error {
var (
downloadURL string
fileName = "cloudflared"
)
if _, err := os.Stat("data"); os.IsNotExist(err) {
os.Mkdir("data", 0755)
}
switch runtime.GOOS {
case "windows":
fileName = "cloudflared.exe"
switch runtime.GOARCH {
case "amd64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
case "386":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-386.exe"
default:
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
}
case "linux":
switch runtime.GOARCH {
case "amd64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64"
case "386":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-386"
case "arm":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm"
case "arm64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64"
default:
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
}
case "darwin":
switch runtime.GOARCH {
case "amd64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
case "arm64":
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz"
default:
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
}
default:
return fmt.Errorf("unsupported os: %s", runtime.GOOS)
}
filePath := "data/" + fileName
if _, err := os.Stat(filePath); os.IsNotExist(err) {
err := downloadCloudflared(downloadURL, filePath)
if err != nil {
return err
}
}
args := []string{"tunnel", "--no-autoupdate", "run", "--token"}
token := os.Getenv("KOMARI_CLOUDFLARED_TOKEN")
args = append(args, token)
cmd := exec.Command(filePath, args...)
cloudflaredCmd = cmd
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
log.Printf("[cloudflared] %s", scanner.Text())
}
}()
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
log.Printf("[cloudflared] %s", scanner.Text())
}
}()
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("cloudflared exited with error: %v", err)
} else {
log.Println("cloudflared exited successfully")
}
os.Exit(1)
}()
log.Println("cloudflared started")
return nil
}
func Kill() {
if cloudflaredCmd != nil && cloudflaredCmd.Process != nil {
err := cloudflaredCmd.Process.Kill()
if err != nil {
log.Printf("failed to kill cloudflared: %v", err)
} else {
log.Println("cloudflared killed")
}
cloudflaredCmd = nil
}
}
+21
View File
@@ -0,0 +1,21 @@
package cloudflared_test
import (
"os"
"testing"
"time"
"github.com/komari-monitor/komari/utils/cloudflared"
)
func TestRunCloudflared(t *testing.T) {
os.Setenv("KOMARI_CLOUDFLARED_TOKEN", "test-token")
err := cloudflared.RunCloudflared()
if err != nil {
t.Fatalf("RunCloudflared failed: %v", err)
}
// 等待一段时间,确保子进程已启动
time.Sleep(2 * time.Second)
}
+4
View File
@@ -7,6 +7,10 @@ import (
type EmptyProvider struct{}
func (e *EmptyProvider) Name() string {
return "EmptyProvider"
}
func (e *EmptyProvider) Initialize() error {
return nil
}
+44
View File
@@ -4,12 +4,15 @@ import (
"log"
"net"
"strings"
"time"
"unicode"
"github.com/komari-monitor/komari/database/config"
"github.com/patrickmn/go-cache"
)
var CurrentProvider GeoIPService
var geoCache *cache.Cache
type GeoInfo struct {
ISOCode string
@@ -18,11 +21,14 @@ type GeoInfo struct {
func init() {
CurrentProvider = &EmptyProvider{}
geoCache = cache.New(48*time.Hour, 1*time.Hour)
}
// GeoIPService 接口定义了获取地理位置信息的核心方法。
// 任何实现此接口的类型都可以作为地理位置服务提供者。
type GeoIPService interface {
Name() string
GetGeoInfo(ip net.IP) (*GeoInfo, error)
UpdateDatabase() error
@@ -89,7 +95,45 @@ func InitGeoIp() {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize GeoJS service, using EmptyProvider instead.")
}
case "ipinfo":
NewCurrentProvider, err := NewIPInfoService()
if err != nil {
log.Printf("Failed to initialize IPInfo service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using ipinfo.io as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize IPInfo service, using EmptyProvider instead.")
}
default:
CurrentProvider = &EmptyProvider{}
}
}
func GetGeoInfo(ip net.IP) (*GeoInfo, error) {
providerName := CurrentProvider.Name()
cacheKey := providerName + ":" + ip.String()
if cachedInfo, found := geoCache.Get(cacheKey); found {
//log.Println("GeoIP cache hit for", cacheKey)
return cachedInfo.(*GeoInfo), nil
}
info, err := CurrentProvider.GetGeoInfo(ip)
if err == nil && info != nil {
//log.Println("GeoIP cache miss for", cacheKey)
geoCache.Set(cacheKey, info, cache.DefaultExpiration)
}
return info, err
}
func UpdateDatabase() error {
err := CurrentProvider.UpdateDatabase()
if err == nil {
geoCache.Flush()
log.Println("GeoIP cache cleared due to database update.")
}
return err
}
+6 -2
View File
@@ -22,11 +22,15 @@ func TestGeojs(t *testing.T) {
testIpAddr(t)
}
func TestIpInfo(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewIPInfoService()
testIpAddr(t)
}
func testIpAddr(t *testing.T) {
// IPv4
ipaddr := "8.8.8.8"
ip := net.ParseIP(ipaddr)
record, err := geoip.CurrentProvider.GetGeoInfo(ip)
record, err := geoip.GetGeoInfo(ip)
if err != nil {
t.Errorf("Failed to get GeoIP info for IP %s: %v", ipaddr, err)
}
@@ -44,7 +48,7 @@ func testIpAddr(t *testing.T) {
// IPv6
ipaddr = "2001:4860:4860::8888"
ip = net.ParseIP(ipaddr)
record, err = geoip.CurrentProvider.GetGeoInfo(ip)
record, err = geoip.GetGeoInfo(ip)
if err != nil {
t.Errorf("Failed to get GeoIP info for IPv6 %s: %v", ipaddr, err)
}
+5
View File
@@ -32,6 +32,11 @@ func NewGeoJSService() (*GeoJSService, error) {
}, nil
}
// Name 返回服务的名称。
func (s *GeoJSService) Name() string {
return "geojs.io"
}
// GetGeoInfo 使用 geojs.io 服务检索给定 IP 地址的地理位置信息。
func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// GeoJS 的 API 端点
+4
View File
@@ -32,6 +32,10 @@ type ipAPIResponse struct {
Query string `json:"query"`
}
func (s *IPAPIService) Name() string {
return "ip-api.com"
}
// NewIPAPIService 创建并返回一个 IPAPIService 的新实例。
func NewIPAPIService() (*IPAPIService, error) {
return &IPAPIService{
+91
View File
@@ -0,0 +1,91 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// IPInfoService 使用 ipinfo.io 服务实现 GeoIPService 接口。
type IPInfoService struct {
Client *http.Client
// 每天 1000 次请求,限制由 IP 地址的所有人共享。
// APIToken string
}
// ipInfoResponse 定义了 ipinfo.io 服务返回的 JSON 响应的结构,只包含免费额度可用的字段。
type ipInfoResponse struct {
IP string `json:"ip"`
Hostname string `json:"hostname"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
CountryCode string `json:"countryCode"` // ipinfo.io 返回 "country" 的 ISO 代码,这里为了与 GeoInfo 保持一致,额外添加一个 CountryCode
Loc string `json:"loc"` // Latitude,Longitude
Org string `json:"org"`
Postal string `json:"postal"`
Timezone string `json:"timezone"`
}
// NewIPInfoService 创建并返回一个 IPInfoService 的新实例。
func NewIPInfoService() (*IPInfoService, error) {
return &IPInfoService{
Client: &http.Client{
Timeout: 5 * time.Second,
},
}, nil
}
// Name 返回服务的名称。
func (s *IPInfoService) Name() string {
return "ipinfo.io"
}
// GetGeoInfo 使用 ipinfo.io 服务检索给定 IP 地址的地理位置信息。
// 免费额度主要提供国家信息。
func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// IPinfo 免费额度不需要 API token 就可以查询基本的 IP 信息。
// API URL: https://ipinfo.io/json (查询自身IP) 或 https://ipinfo.io/YOUR_IP/json
apiURL := fmt.Sprintf("https://ipinfo.io/%s/json", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from ipinfo.io: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ipinfo.io returned non-200 status: %d %s", resp.StatusCode, resp.Status)
}
var apiResp ipInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode ipinfo.io response: %w", err)
}
// IPinfo 的 "country" 字段直接返回 ISO 2-letter code,例如 "US", "CN"
// 我们需要将 "country" 字段作为 ISOCode,并尝试获取其对应的国家名称。
// IPinfo 响应中通常不直接提供完整的国家名称,但我们可以通过 CountryCode 映射。
// 为了简化并符合 GeoInfo 结构,我们直接使用 Country 作为 ISOCode,并尝试从 CountryCode 获取名称。
// 实际上,IPinfo 的 'country' 字段就是 ISO 2-letter code。
// 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。
// 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。
return &GeoInfo{
ISOCode: apiResp.Country, // IPinfo 的 'country' 字段就是 ISO 2-letter code
Name: apiResp.Country, // 免费额度通常只提供 ISO 编码,这里暂时用 ISO 编码作为名称
}, nil
}
// UpdateDatabase 对于 ipinfo.io 是一个空操作,因为它是一个 Web 服务。
func (s *IPInfoService) UpdateDatabase() error {
// 无需执行任何操作,因为数据由外部服务提供
return nil
}
// Close 对于 ipinfo.io 是一个空操作,因为没有需要关闭的持久连接。
func (s *IPInfoService) Close() error {
// 无需执行任何操作
return nil
}
+6 -1
View File
@@ -40,6 +40,11 @@ type MaxMindGeoIPService struct {
mu sync.RWMutex
}
// Name 返回服务的名称。
func (s *MaxMindGeoIPService) Name() string {
return "MaxMind"
}
// NewMaxMindGeoIPService 创建并返回一个 MaxMindGeoIPService 实例。
// 它负责初始化服务,包括尝试加载或下载数据库。
func NewMaxMindGeoIPService() (*MaxMindGeoIPService, error) {
@@ -128,7 +133,6 @@ func (s *MaxMindGeoIPService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// 它会下载最新的 GeoLite2-Country.mmdb 文件并重新加载数据库。
func (s *MaxMindGeoIPService) UpdateDatabase() error {
s.mu.Lock() // 获取写锁,确保更新过程的互斥性
defer s.mu.Unlock()
resp, err := http.Get(GeoIpUrl) // GeoIpUrl 是预定义的 MaxMind 数据库下载地址
if err != nil {
@@ -155,6 +159,7 @@ func (s *MaxMindGeoIPService) UpdateDatabase() error {
if err != nil {
return fmt.Errorf("failed to write MaxMind database file: %w", err)
}
s.mu.Unlock() // initialize 方法需要在解锁后调用,以避免死锁
// 重新加载数据库以使用新下载的文件
return s.initialize()
}
+91
View File
@@ -0,0 +1,91 @@
package messageSender
import (
"crypto/tls"
"fmt"
"net/smtp"
"strconv"
"github.com/komari-monitor/komari/database/config"
)
// EmailMessageSender implements the MessageSender interface for Email.
type EmailMessageSender struct{}
// SendTextMessage sends a text message via Email (SMTP).
// The title is used as the email subject.
func (e *EmailMessageSender) SendTextMessage(message, title string) error {
conf, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}
if conf.EmailHost == "" || conf.EmailSender == "" || conf.EmailUsername == "" || conf.EmailPassword == "" || conf.EmailReceiver == "" {
return fmt.Errorf("email sending is not fully configured")
}
auth := smtp.PlainAuth(
"",
conf.EmailUsername,
conf.EmailPassword,
conf.EmailHost,
)
msg := []byte("To: " + conf.EmailReceiver + "\r\n" +
"From: " + conf.EmailSender + "\r\n" +
"Subject: " + title + "\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" +
message)
addr := conf.EmailHost + ":" + strconv.Itoa(conf.EmailPort)
if conf.EmailUseSSL {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
if err = c.StartTLS(&tls.Config{ServerName: conf.EmailHost}); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
// Authenticate
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
// Set the sender and recipient
if err = c.Mail(conf.EmailSender); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err = c.Rcpt(conf.EmailReceiver); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg)
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return c.Quit()
} else {
// Send without SSL/TLS (less secure)
return smtp.SendMail(
addr,
auth,
conf.EmailSender,
[]string{conf.EmailReceiver},
msg,
)
}
}
+9
View File
@@ -0,0 +1,9 @@
package messageSender
import "fmt"
type EmptyProvider struct{}
func (e *EmptyProvider) SendTextMessage(message, title string) error {
return fmt.Errorf("you are using an empty message sender, please check your configuration")
}
+36 -10
View File
@@ -1,22 +1,48 @@
package messageSender
import (
"errors"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
)
func SendTextMessage(message string) error {
cfg, _ := config.Get()
if !cfg.NotificationEnabled {
return nil
var CurrentProvider MessageSender
func init() {
CurrentProvider = &EmptyProvider{}
}
func Initialize() {
cfg, err := config.Get()
if err != nil {
CurrentProvider = &EmptyProvider{}
return
}
switch cfg.NotificationMethod {
case "none":
return nil
case "telegram":
return TelegramSendTextMessage(message)
CurrentProvider = &TelegramMessageSender{}
case "email":
CurrentProvider = &EmailMessageSender{}
case "none", "":
CurrentProvider = &EmptyProvider{}
default:
return errors.New("unsupported notification method: " + cfg.NotificationMethod)
CurrentProvider = &EmptyProvider{}
}
}
type MessageSender interface {
SendTextMessage(message, title string) error
}
func SendTextMessage(message string, title string) error {
var err error
for i := 0; i < 3; i++ {
err = CurrentProvider.SendTextMessage(message, title)
if err == nil {
logOperation.Log("", "", "Message sent: "+title, "info")
return nil
}
}
logOperation.Log("", "", "Failed to send message after 3 attempts: "+err.Error()+","+title, "error")
return err
}
+47 -59
View File
@@ -10,67 +10,55 @@ import (
"github.com/komari-monitor/komari/database/config"
)
func TelegramSendMessage(message, msgType string) error {
var lastErr error
for i := 0; i < 3; i++ {
conf, err := config.Get()
if err != nil {
lastErr = err
continue
}
// TelegramMessageSender implements the MessageSender interface for Telegram.
type TelegramMessageSender struct{}
if 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 {
lastErr = fmt.Errorf("failed to send message: %v", err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("telegram API returned non-OK status: %d", resp.StatusCode)
continue
}
var result struct {
Ok bool `json:"ok"`
Description string `json:"description"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
lastErr = err
continue
}
if !result.Ok {
lastErr = fmt.Errorf("telegram API error: %s", result.Description)
continue
}
return nil
// SendTextMessage sends a text message via Telegram API.
// The title is prepended to the message.
func (t *TelegramMessageSender) SendTextMessage(message, title string) error {
fullMessage := message
if title != "" {
fullMessage = fmt.Sprintf("<b>%s</b>\n%s", title, message)
}
return lastErr
}
// SendTextMessage sends a text message via Telegram API
func TelegramSendTextMessage(message string) error {
return TelegramSendMessage(message, "text")
}
var err error
conf, err := config.Get()
if err != nil {
return err
}
// SendMarkdownMessage sends a message formatted in Markdown via Telegram API
func TelegramSendMarkdownMessage(message string) error {
return TelegramSendMessage(message, "MarkdownV2")
}
func TelegramSendHTMLMessage(message string) error {
return TelegramSendMessage(message, "HTML")
if fullMessage == "" {
return errors.New("message is empty")
}
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
data := url.Values{}
data.Set("chat_id", conf.TelegramChatID)
data.Set("text", fullMessage)
data.Set("parse_mode", "HTML")
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
}
+27 -11
View File
@@ -2,6 +2,7 @@ package notification
import (
"fmt"
"math"
"time"
"github.com/komari-monitor/komari/database/clients"
@@ -12,9 +13,8 @@ import (
func CheckExpireScheduledWork() {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 17, 0, 0, 0, now.Location())
next := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location()) // UTC 9AM = CST 17PM
if now.After(next) {
// 如果已经过了今天的17:00,则设为明天的17:00
next = next.Add(24 * time.Hour)
}
duration := next.Sub(now)
@@ -38,25 +38,41 @@ func CheckExpireScheduledWork() {
continue
}
var clientLeadToExpire []string
type clientToExpireInfo struct {
Name string
DaysLeft int
}
var clientLeadToExpire []clientToExpireInfo
checkTime := time.Now()
for _, client := range clients_all {
if client.ExpiredAt.ToTime().Before(now) {
clientExpireTime := client.ExpiredAt.ToTime()
if clientExpireTime.Before(checkTime) {
continue
}
notificationThreshold := now.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
if client.ExpiredAt.ToTime().Before(notificationThreshold) || client.ExpiredAt.ToTime().Equal(notificationThreshold) {
clientLeadToExpire = append(clientLeadToExpire, client.Name)
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
if len(clientLeadToExpire) > 0 {
message := "The following clients are about to expire: \n"
for i := 0; i < len(clientLeadToExpire); i++ {
message += fmt.Sprintf("%s in %d days.\n", clientLeadToExpire[i], notificationLeadDays)
message := "The following clients are about to expire: \n\n"
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("%s in %d days.\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendTextMessage(message)
messageSender.SendTextMessage(message, "Komari Expiration Notification")
}
// 等待1秒,防止多次触发
+220
View File
@@ -0,0 +1,220 @@
package notification
import (
"fmt"
"log"
"reflect"
"sync"
"time"
"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/records"
"github.com/komari-monitor/komari/utils/messageSender"
)
// LoadNotificationService 管理定时器和任务
type LoadNotificationService struct {
mu sync.Mutex
tickers map[int]*time.Ticker
tasks map[int][]models.LoadNotification
stopChan chan struct{}
}
var LoadNotificationManager = &LoadNotificationService{
tickers: make(map[int]*time.Ticker),
tasks: make(map[int][]models.LoadNotification),
stopChan: make(chan struct{}),
}
// Reload 重载时间表
func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotification) error {
m.mu.Lock()
defer m.mu.Unlock()
// 停止所有现有定时器
for _, ticker := range m.tickers {
ticker.Stop()
}
m.tickers = make(map[int]*time.Ticker)
m.tasks = make(map[int][]models.LoadNotification)
// 按Interval分组任务
taskGroups := make(map[int][]models.LoadNotification)
for _, task := range loadNotifications {
taskGroups[task.Interval] = append(taskGroups[task.Interval], task)
}
// 为每个唯一的Interval创建定时器
for interval, tasks := range taskGroups {
ticker := time.NewTicker(time.Duration(interval) * time.Minute)
m.tickers[interval] = ticker
m.tasks[interval] = tasks
go func(ticker *time.Ticker, tasks []models.LoadNotification) {
for {
select {
case <-ticker.C:
for _, task := range tasks {
go executeLoadNotificationTask(task)
}
case <-m.stopChan:
return
}
}
}(ticker, tasks)
}
return nil
}
// executeLoadNotificationTask 执行单个LoadNotificationTask
func executeLoadNotificationTask(task models.LoadNotification) {
// 检查是否在冷却期内
if shouldSkipNotification(task) {
return
}
now := time.Now()
windowStart := now.Add(-time.Duration(task.Interval) * time.Minute)
overloadClients := make([]string, 0)
for _, clientUUID := range task.Clients {
// 获取客户端在时间窗口内的记录
records, err := getRecordsForClient(clientUUID, windowStart, now)
if err != nil {
continue
}
// 检查指标是否达到阈值
if checkMetricThreshold(records, task) {
overloadClients = append(overloadClients, clientUUID)
}
}
sendLoadNotification(overloadClients, task)
updateLastNotified(task.Id, now)
}
// shouldSkipNotification 检查是否应该跳过通知(冷却期检查)
func shouldSkipNotification(task models.LoadNotification) bool {
if task.LastNotified.ToTime().IsZero() {
return false
}
// 计算冷却期(使用 interval 作为冷却期)
cooldownPeriod := time.Duration(task.Interval) * time.Minute
timeSinceLastNotified := time.Since(task.LastNotified.ToTime())
return timeSinceLastNotified < cooldownPeriod
}
// getRecordsForClient 获取指定客户端在时间窗口内的记录
func getRecordsForClient(clientUUID string, start, end time.Time) ([]models.Record, error) {
return records.GetRecordsByClientAndTime(clientUUID, start, end)
}
// checkMetricThreshold 检查指标是否达到阈值
func checkMetricThreshold(records []models.Record, task models.LoadNotification) bool {
if len(records) == 0 {
return false
}
// 计算需要达标的最小记录数
minRequiredRecords := int(float32(len(records)) * task.Ratio)
if minRequiredRecords == 0 {
minRequiredRecords = 1
}
exceededCount := 0
for _, record := range records {
metricValue := getMetricValue(record, task.Metric)
if metricValue >= task.Threshold {
exceededCount++
}
}
return exceededCount >= minRequiredRecords
}
// getMetricValue 根据指标名称获取记录中的对应值
func getMetricValue(record models.Record, metric string) float32 {
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
switch metric {
case "cpu":
return record.Cpu
case "gpu":
return record.Gpu
case "ram":
if record.RamTotal > 0 {
return float32(record.Ram) / float32(client.MemTotal) * 100
}
return 0
case "swap":
if record.SwapTotal > 0 {
return float32(record.Swap) / float32(client.SwapTotal) * 100
}
return 0
case "load":
return record.Load
case "temp":
return record.Temp
case "disk":
if record.DiskTotal > 0 {
return float32(record.Disk) / float32(client.DiskTotal) * 100
}
return 0
default:
// 尝试通过反射获取字段值
v := reflect.ValueOf(record)
field := v.FieldByName(metric)
if field.IsValid() && field.CanInterface() {
switch field.Kind() {
case reflect.Float32:
return float32(field.Float())
case reflect.Float64:
return float32(field.Float())
case reflect.Int, reflect.Int32, reflect.Int64:
return float32(field.Int())
}
}
return 0
}
}
// sendLoadNotification 发送负载通知
func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
message := "The following clients have exceeded the load threshold:\n\n"
for _, clientUUID := range clientUUIDs {
client, err := clients.GetClientByUUID(clientUUID)
if err != nil {
log.Printf("Failed to get client info for %s: %v", clientUUID, err)
continue
}
message += fmt.Sprintf("%s\n", client.Name)
}
if len(clientUUIDs) == 0 {
return
}
go func() {
messageSender.SendTextMessage(message, fmt.Sprintf("%s - Komari Load Alert", task.Name))
}()
}
// updateLastNotified 更新最后通知时间
func updateLastNotified(taskId uint, notifyTime time.Time) {
db := dbcore.GetDBInstance()
if err := db.Model(&models.LoadNotification{}).Where("id = ?", taskId).Update("last_notified", notifyTime).Error; err != nil {
log.Printf("Failed to update last_notified for task %d: %v", taskId, err)
}
}
// ReloadLoadNotificationSchedule 加载或重载时间表
func ReloadLoadNotificationSchedule(loadNotifications []models.LoadNotification) error {
return LoadNotificationManager.Reload(loadNotifications)
}
+2 -2
View File
@@ -100,7 +100,7 @@ func OfflineNotification(clientID string) {
// Send notification
message := fmt.Sprintf("🔴%s is offline", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg); err != nil {
if err := messageSender.SendTextMessage(msg, "Komari Offline Notification"); err != nil {
log.Println("Failed to send offline notification:", err)
}
}(message)
@@ -151,7 +151,7 @@ func OnlineNotification(clientID string) {
// 规则3:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
message := fmt.Sprintf("🟢%s is online", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg); err != nil {
if err := messageSender.SendTextMessage(msg, "Komari Online Notification"); err != nil {
log.Println("Failed to send online notification:", err)
}
}(message)
+99 -38
View File
@@ -1,59 +1,120 @@
package utils
import (
"sort"
"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
// AverageReport 根据 topPercentage 参数计算报告的平均值。
// 如果 topPercentage 为 0,则计算所有记录的平均值。
// 如果 topPercentage 大于 0 且小于等于 1,则计算从大到小排序后的前 topPercentage 记录的平均值。
func AverageReport(uuid string, time time.Time, records []common.Report, topPercentage float64) models.Record {
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
recordsToAverageCount := count
if topPercentage > 0 && topPercentage <= 1 {
recordsToAverageCount = int(float64(count) * topPercentage)
if recordsToAverageCount == 0 && count > 0 {
recordsToAverageCount = 1 // 确保至少选择一个记录,除非总数为0
}
}
// Create new aggregated record
// 定义一个辅助函数,用于排序和求和
sumAndSort := func(getFloat32Value func(common.Report) float32, getInt64Value func(common.Report) int64, isFloat bool) (float32, int64) {
if isFloat {
sort.Slice(records, func(i, j int) bool {
return getFloat32Value(records[i]) > getFloat32Value(records[j])
})
var sum float32
for i := 0; i < recordsToAverageCount; i++ {
sum += getFloat32Value(records[i])
}
return sum, 0
} else {
sort.Slice(records, func(i, j int) bool {
return getInt64Value(records[i]) > getInt64Value(records[j])
})
var sum int64
for i := 0; i < recordsToAverageCount; i++ {
sum += getInt64Value(records[i])
}
return 0, sum
}
}
var sumCPU, sumLOAD float32
var sumRAM, sumRAMTotal, sumSWAP, sumSWAPTotal, sumDISK, sumDISKTotal, sumNETIn, sumNETOut, sumNETTotalUp, sumNETTotalDown int64
var sumPROCESS, sumConnections, sumConnectionsUDP int
if topPercentage > 0 && topPercentage <= 1 {
sumCPU, _ = sumAndSort(func(r common.Report) float32 { return float32(r.CPU.Usage) }, nil, true)
sumLOAD, _ = sumAndSort(func(r common.Report) float32 { return float32(r.Load.Load1) }, nil, true)
_, sumRAM = sumAndSort(nil, func(r common.Report) int64 { return r.Ram.Used }, false)
//_, sumRAMTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Ram.Total }, false)
_, sumSWAP = sumAndSort(nil, func(r common.Report) int64 { return r.Swap.Used }, false)
//_, sumSWAPTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Swap.Total }, false)
_, sumDISK = sumAndSort(nil, func(r common.Report) int64 { return r.Disk.Used }, false)
//_, sumDISKTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Disk.Total }, false)
_, sumNETIn = sumAndSort(nil, func(r common.Report) int64 { return r.Network.Down }, false)
_, sumNETOut = sumAndSort(nil, func(r common.Report) int64 { return r.Network.Up }, false)
_, sumNETTotalUp = sumAndSort(nil, func(r common.Report) int64 { return r.Network.TotalUp }, false)
_, sumNETTotalDown = sumAndSort(nil, func(r common.Report) int64 { return r.Network.TotalDown }, false)
_, sumInt := sumAndSort(nil, func(r common.Report) int64 { return int64(r.Process) }, false)
sumPROCESS = int(sumInt)
_, sumInt = sumAndSort(nil, func(r common.Report) int64 { return int64(r.Connections.TCP) }, false)
sumConnections = int(sumInt)
_, sumInt = sumAndSort(nil, func(r common.Report) int64 { return int64(r.Connections.UDP) }, false)
sumConnectionsUDP = int(sumInt)
} else { // 计算所有记录的平均值
for _, r := range records {
sumCPU += float32(r.CPU.Usage)
sumLOAD += float32(r.Load.Load1)
sumRAM += r.Ram.Used
sumRAMTotal += r.Ram.Total
sumSWAP += r.Swap.Used
sumSWAPTotal += r.Swap.Total
sumDISK += r.Disk.Used
sumDISKTotal += r.Disk.Total
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
}
}
// 创建新的聚合记录
newRecord := models.Record{
Client: uuid,
Time: models.FromTime(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,
Cpu: sumCPU / float32(recordsToAverageCount),
Gpu: 0, // 保持原始行为
Ram: sumRAM / int64(recordsToAverageCount),
RamTotal: records[0].Ram.Total,
Swap: sumSWAP / int64(recordsToAverageCount),
SwapTotal: records[0].Swap.Total,
Load: sumLOAD / float32(recordsToAverageCount),
Temp: 0, // 保持原始行为
Disk: sumDISK / int64(recordsToAverageCount),
DiskTotal: records[0].Disk.Total,
NetIn: sumNETIn / int64(recordsToAverageCount),
NetOut: sumNETOut / int64(recordsToAverageCount),
NetTotalUp: sumNETTotalUp / int64(recordsToAverageCount),
NetTotalDown: sumNETTotalDown / int64(recordsToAverageCount),
Process: sumPROCESS / recordsToAverageCount,
Connections: sumConnections / recordsToAverageCount,
ConnectionsUdp: sumConnectionsUDP / recordsToAverageCount,
}
return newRecord
}