Compare commits

..
27 Commits
Author SHA1 Message Date
Akizon 862657ea86 Merge pull request #83 from komari-monitor/dev
feat: 在获取的任务中添加间隔时间字段
2025-06-30 18:38:06 +08:00
Akizon77 70b519a345 feat: 在获取的任务中添加间隔时间字段 2025-06-30 18:09:31 +08:00
Akizon 6600e4014d Merge pull request #82 from komari-monitor/dev
feat: #53 通知方式兼容SMTP邮件、支持下载备份
2025-06-29 23:50:00 +08:00
Akizon77 b8a6f18a77 feat: 支持下载备份 2025-06-29 23:29:19 +08:00
Akizon77 03e08cdfbb fix: 只有在通知方式变动时重载MessageSenderProvider 2025-06-29 22:30:28 +08:00
Akizon77 b07f7345ee feat: 通知方式兼容SMTP邮件 2025-06-29 22:28:04 +08:00
Akizon77 e85996178a refactor: 抽象消息发送接口 2025-06-29 22:17:35 +08:00
Akizon77 5547fcd067 ci: 发布main分支的docker镜像,修正release 2025-06-29 21:54:41 +08:00
Akizon aef4344997 Merge pull request #81 from komari-monitor/dev
fix: #79 GeoIP无法更新
feat: IP查询兼容 ipinfo.io
2025-06-29 21:44:43 +08:00
Akizon77 061197cd77 ci: 发布到测试环境不显示部分日志 2025-06-29 21:40:14 +08:00
Akizon77 c00d1d0cb2 ci: 发布docker镜像时预编译二进制 2025-06-29 21:36:30 +08:00
Akizon77 b6a7511e33 fix: #79 GeoIP无法更新 2025-06-29 01:31:54 +08:00
Akizon77 13450a50eb feat: IP查询兼容 ipinfo.io 2025-06-28 21:04:37 +08:00
Akizon eb883ccc6f Merge pull request #78 from komari-monitor/dev
feat: 到期提醒显示具体还剩多少天
2025-06-28 20:20:43 +08:00
Akizon77 2ae9b3516c fix: MaxMind 数据库 initialize 方法需要在解锁后调用,以避免死锁 2025-06-28 20:15:31 +08:00
Akizon77 7dc2a0294e feat: 到期提醒显示具体还剩多少天 2025-06-28 20:02:32 +08:00
Akizon f97b91c19c Merge pull request #77 from komari-monitor/dev
feat: 添加过期通知功能,支持配置过期前通知天数
2025-06-27 23:10:54 +08:00
Akizon77 bfac98dc67 feat: 添加过期通知功能,支持配置过期前通知天数 2025-06-27 22:28:42 +08:00
Akizon77 0f0f9f5985 feat: 添加记录删除功能,支持在记录禁用时清除所有记录 2025-06-27 22:13:24 +08:00
Akizon efebe66518 Merge pull request #76 from komari-monitor/dev
feat: 添加对ip-api和geojs服务的支持
2025-06-27 21:38:10 +08:00
Akizon77 37a05e8f3d fix: 更新时间条件,调整为四小时之前的记录查询 2025-06-27 21:36:04 +08:00
Akizon77 7473c438c2 feat: 订阅配置变化更新geoip配置 2025-06-27 21:22:50 +08:00
Akizon77 6e4e6d0653 feat: 添加对ip-api和geojs服务的支持,更新GeoIP配置 2025-06-27 21:20:31 +08:00
Akizon77 c09bec5c20 refactor: 更新GeoIP服务实现 2025-06-27 21:05:59 +08:00
Akizon77 14ce2b8f53 fix: 调整高位百分比和删除旧数据的时间条件 2025-06-27 20:26:49 +08:00
Akizon 8ecb6aa7aa Merge pull request #75 from komari-monitor/dev
ci: docker发布
2025-06-27 17:36:26 +08:00
Akizon77 cedc144173 ci: docker发布 2025-06-27 17:34:26 +08:00
30 changed files with 1133 additions and 223 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 }}
+98
View File
@@ -0,0 +1,98 @@
name: Publish Docker Image
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-multi-arch:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig
run: |
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build static binaries for multi-arch
env:
GOOS: linux
CGO_ENABLED: 1
GIN_MODE: release
run: |
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-34
View File
@@ -117,37 +117,3 @@ jobs:
# Upload the binary as a release asset using GitHub CLI
gh release upload ${{ github.event.release.tag_name }} $BINARY_NAME --repo ${{ github.repository }}
shell: bash
build-and-push-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.event.release.tag_name }}
VERSION_HASH=${{ github.sha }}
+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"]
+130
View File
@@ -0,0 +1,130 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"log"
"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 目录下,单独添加
dbFilePath := flags.DatabaseFile
dbFileName := filepath.Base(dbFilePath)
if !strings.HasPrefix(filepath.Clean(dbFilePath), filepath.Clean("./data")) {
// 不在 ./data,单独添加
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()
// 直接用文件名放在 zip 根目录
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
@@ -0,0 +1 @@
package notification
+10
View File
@@ -7,6 +7,8 @@ import (
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/gin-gonic/gin"
)
@@ -69,3 +71,11 @@ func contains(slice []string, item string) bool {
}
return false
}
func ClearAllRecords(c *gin.Context) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
api.RespondSuccess(c, nil)
}
+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.GetGeoIpInfo(net.ParseIP(ip))
GeoIpRecord, err := geoip.CurrentProvider.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.UpdateGeoIpDatabase(); err != nil {
if err := geoip.CurrentProvider.UpdateDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
+4 -4
View File
@@ -29,15 +29,15 @@ func UploadBasicInfo(c *gin.Context) {
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoIpInfo(ip4)
ip4_record, _ := geoip.CurrentProvider.GetGeoInfo(ip4)
if ip4_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.Country.ISOCode)
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
}
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
ip6 := net.ParseIP(ipv6)
ip6_record, _ := geoip.GetGeoIpInfo(ip6)
ip6_record, _ := geoip.CurrentProvider.GetGeoInfo(ip6)
if ip6_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.Country.ISOCode)
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,
})
}
}
+16
View File
@@ -30,6 +30,8 @@ import (
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/utils"
"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"
)
@@ -53,6 +55,7 @@ var ServerCmd = &cobra.Command{
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
r := gin.Default()
@@ -64,6 +67,12 @@ var ServerCmd = &cobra.Command{
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
DynamicCorsEnabled = event.New.AllowCors
if event.New.GeoIpProvider != event.Old.GeoIpProvider {
go geoip.InitGeoIp()
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
go messageSender.Initialize()
}
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
@@ -117,6 +126,7 @@ var ServerCmd = &cobra.Command{
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
@@ -162,6 +172,7 @@ var ServerCmd = &cobra.Command{
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
@@ -284,6 +295,7 @@ func DoScheduledWork() {
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
go u_notification.CheckExpireScheduledWork()
for {
select {
case <-ticker.C:
@@ -294,6 +306,10 @@ func DoScheduledWork() {
logOperation.RemoveOldLogs()
case <-minute.C:
api.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
}
}
+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
+14 -4
View File
@@ -7,7 +7,7 @@ type Config struct {
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
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// OAuth 配置
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
@@ -21,13 +21,23 @@ 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'"`
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:72"` // Ping 记录保留时间,单位小时,默认3天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt UTCTime
UpdatedAt UTCTime
}
+10 -9
View File
@@ -42,13 +42,13 @@ func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Reco
db := dbcore.GetDBInstance()
var records []models.Record
threeHoursAgo := time.Now().Add(-3 * time.Hour)
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recentRecords []models.Record
recentStart := start
if end.After(threeHoursAgo) {
if recentStart.Before(threeHoursAgo) {
recentStart = threeHoursAgo
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).Order("time ASC").Find(&recentRecords).Error
if err != nil {
@@ -122,7 +122,7 @@ func CompactRecord() error {
log.Printf("Error vacuuming database: %v", err)
}
}
log.Printf("Record compaction completed")
//log.Printf("Record compaction completed")
return nil
}
@@ -251,7 +251,8 @@ func migrateOldRecords(db *gorm.DB) error {
for i, v := range data.Temp {
tempFloats[i] = float64(v)
}
high_percentile := 0.8
// 取高位
high_percentile := 0.7
// 检查 records_long_term 表中是否已存在相同的记录
var existingCount int64
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Count(&existingCount).Error; err != nil {
@@ -271,8 +272,8 @@ func migrateOldRecords(db *gorm.DB) error {
SwapTotal: getIntPercentile(data.SwapTotal, high_percentile),
Disk: getIntPercentile(data.Disk, high_percentile),
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
NetIn: getIntPercentile(data.NetIn, high_percentile),
NetOut: getIntPercentile(data.NetOut, high_percentile),
NetIn: getIntPercentile(data.NetIn, 0.2),
NetOut: getIntPercentile(data.NetOut, 0.2),
NetTotalUp: getIntPercentile(data.NetTotalUp, high_percentile),
NetTotalDown: getIntPercentile(data.NetTotalDown, high_percentile),
Process: getInt32Percentile(data.Process, high_percentile),
@@ -293,7 +294,7 @@ func migrateOldRecords(db *gorm.DB) error {
}
// 删除 records 表中的旧数据
if err := tx.Table("records").Where("time < ?", fourHoursAgo).Delete(&models.Record{}).Error; err != nil {
if err := tx.Table("records").Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
return err
}
+8
View File
@@ -85,6 +85,14 @@ func DeletePingRecords(id []uint) error {
return result.Error
}
func DeleteAllPingRecords() error {
db := dbcore.GetDBInstance()
result := db.Exec("DELETE FROM ping_records")
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
}
func ReloadPingSchedule() error {
db := dbcore.GetDBInstance()
var pingTasks []models.PingTask
+21
View File
@@ -0,0 +1,21 @@
package geoip
import (
"fmt"
"net"
)
type EmptyProvider struct{}
func (e *EmptyProvider) Initialize() error {
return nil
}
func (e *EmptyProvider) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
return nil, fmt.Errorf("you are using an empty GeoIP provider, please set a valid provider")
}
func (e *EmptyProvider) UpdateDatabase() error {
return fmt.Errorf("you are using an empty GeoIP provider, please set a valid provider")
}
func (e *EmptyProvider) Close() error {
return nil
}
+75 -81
View File
@@ -1,100 +1,33 @@
package geoip
import (
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"unicode"
"github.com/oschwald/maxminddb-golang"
"github.com/komari-monitor/komari/database/config"
)
var (
GeoIpUrl = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
GeoIpFilePath = "./data/GeoLite2-Country.mmdb"
geoIpDb *maxminddb.Reader = nil
lock = &sync.RWMutex{}
)
var CurrentProvider GeoIPService
type GeoIpRecord struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
type GeoInfo struct {
ISOCode string
Name string
}
// 更新Geoip数据库,使用 GeoIpUrl下载最新的数据库文件,并覆盖本地的 GeoIpFilePath 文件
func UpdateGeoIpDatabase() error {
lock.Lock()
defer lock.Unlock()
if geoIpDb != nil {
geoIpDb.Close()
geoIpDb = nil
}
log.Println("Downloading GeoIP database...")
resp, err := http.Get(GeoIpUrl)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download file: %s", resp.Status)
}
out, err := os.Create(GeoIpFilePath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
return err
}
return nil
func init() {
CurrentProvider = &EmptyProvider{}
}
func InitGeoIp() {
if os.MkdirAll("./data", os.ModePerm) != nil {
return
}
if _, err := os.Stat(GeoIpFilePath); os.IsNotExist(err) {
err := UpdateGeoIpDatabase()
if err != nil {
log.Println("Error updating GeoIP database:", err)
} else {
log.Println("GeoIP database updated successfully.")
}
}
var err error
geoIpDb, err = maxminddb.Open(GeoIpFilePath)
if err != nil {
log.Printf("Error opening GeoIP database: %v", err)
}
}
// GeoIPService 接口定义了获取地理位置信息的核心方法。
// 任何实现此接口的类型都可以作为地理位置服务提供者。
type GeoIPService interface {
GetGeoInfo(ip net.IP) (*GeoInfo, error)
func GetGeoIpInfo(ip net.IP) (*GeoIpRecord, error) {
lock.RLock()
defer lock.RUnlock()
if geoIpDb == nil {
InitGeoIp()
}
if ip == nil {
return nil, fmt.Errorf("IP address is nil")
}
var record GeoIpRecord
err := geoIpDb.Lookup(ip, &record)
if err != nil {
log.Printf("Error looking up IP %s: %v", ip.String(), err)
return nil, err
}
return &record, nil
UpdateDatabase() error
Close() error
}
func GetRegionUnicodeEmoji(isoCode string) string {
@@ -111,3 +44,64 @@ func GetRegionUnicodeEmoji(isoCode string) string {
rune2 := rune(0x1F1E6 + (rune(isoCode[1]) - 'A'))
return string(rune1) + string(rune2)
}
func InitGeoIp() {
conf, err := config.Get()
if err != nil {
panic("Failed to get configuration for GeoIP: " + err.Error())
}
if !conf.GeoIpEnabled {
return
}
switch conf.GeoIpProvider {
case "mmdb":
NewCurrentProvider, err := NewMaxMindGeoIPService()
if err != nil {
log.Printf("Failed to initialize MaxMind GeoIP service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize MaxMind GeoIP service, using EmptyProvider instead.")
}
case "ip-api":
NewCurrentProvider, err := NewIPAPIService()
if err != nil {
log.Printf("Failed to initialize ip-api service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using ip-api.com as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize ip-api service, using EmptyProvider instead.")
}
case "geojs":
NewCurrentProvider, err := NewGeoJSService()
if err != nil {
log.Printf("Failed to initialize GeoJS service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using geojs.io as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize GeoJS service, using EmptyProvider instead.")
}
case "ipinfo":
NewCurrentProvider, err := NewIPInfoService()
if err != nil {
log.Printf("Failed to initialize IPInfo service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using ipinfo.io as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize IPInfo service, using EmptyProvider instead.")
}
default:
CurrentProvider = &EmptyProvider{}
}
}
+23 -17
View File
@@ -2,58 +2,64 @@ package geoip_test
import (
"net"
"os"
"testing"
"github.com/komari-monitor/komari/utils/geoip"
)
// 测试GeoIP数据库的初始化和更新功能
func TestInitGeoIp(t *testing.T) {
geoip.InitGeoIp()
func TestMmdb(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewMaxMindGeoIPService()
testIpAddr(t)
}
func TestIpApi(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewIPAPIService()
testIpAddr(t)
}
// 检查数据库
fileInfo, err := os.Stat(geoip.GeoIpFilePath)
if err != nil {
t.Errorf("Failed to get file info: %v", err)
}
if fileInfo.Size() == 0 {
t.Errorf("GeoIP database file is empty: %s", geoip.GeoIpFilePath)
}
func TestGeojs(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewGeoJSService()
testIpAddr(t)
}
func TestIpInfo(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewIPInfoService()
testIpAddr(t)
}
func testIpAddr(t *testing.T) {
// IPv4
ipaddr := "8.8.8.8"
ip := net.ParseIP(ipaddr)
record, err := geoip.GetGeoIpInfo(ip)
record, err := geoip.CurrentProvider.GetGeoInfo(ip)
if err != nil {
t.Errorf("Failed to get GeoIP info for IP %s: %v", ipaddr, err)
}
if record != nil {
if record.Country.ISOCode == "" && record.Country.Names["zh-CN"] == "" {
if record.ISOCode == "" && record.Name == "" {
t.Errorf("Country information is missing for IP %s", ipaddr)
}
} else {
t.Errorf("GeoIP record is nil for IP %s", ipaddr)
}
t.Logf("IPv4:[%s]%s - %s", ipaddr, record.Country.ISOCode, record.Country.Names["zh-CN"])
t.Logf("IPv4:[%s]%s - %s", ipaddr, record.ISOCode, record.Name)
// IPv6
ipaddr = "2001:4860:4860::8888"
ip = net.ParseIP(ipaddr)
record, err = geoip.GetGeoIpInfo(ip)
record, err = geoip.CurrentProvider.GetGeoInfo(ip)
if err != nil {
t.Errorf("Failed to get GeoIP info for IPv6 %s: %v", ipaddr, err)
}
if record != nil {
if record.Country.ISOCode == "" && record.Country.Names["zh-CN"] == "" {
if record.ISOCode == "" && record.Name == "" {
t.Errorf("Country information is missing for IPv6 %s", ipaddr)
}
} else {
t.Errorf("GeoIP record is nil for IPv6 %s", ipaddr)
}
t.Logf("IPv6:[%s]%s - %s", ipaddr, record.Country.ISOCode, record.Country.Names["zh-CN"])
t.Logf("IPv6:[%s]%s - %s", ipaddr, record.ISOCode, record.Name)
}
func TestUnicodeEmoji(t *testing.T) {
+75
View File
@@ -0,0 +1,75 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// GeoJSService 使用 geojs.io 服务实现 GeoIPService 接口。
type GeoJSService struct {
Client *http.Client
}
// geoJSResponse 定义了 geojs.io 服务返回的 JSON 响应的结构。
// 我们只定义我们需要的字段。
type geoJSResponse struct {
Country string `json:"country"`
CountryCode string `json:"country_code"`
// 可以根据需要添加其他字段,例如:
// City string `json:"city"`
// Region string `json:"region"`
}
// NewGeoJSService 创建并返回一个 GeoJSService 的新实例。
func NewGeoJSService() (*GeoJSService, error) {
return &GeoJSService{
Client: &http.Client{
Timeout: 5 * time.Second, // 设置一个合理的超时时间
},
}, nil
}
// GetGeoInfo 使用 geojs.io 服务检索给定 IP 地址的地理位置信息。
func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// GeoJS 的 API 端点
apiURL := fmt.Sprintf("https://get.geojs.io/v1/ip/geo/%s.json", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from geojs.io: %w", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("geojs.io returned non-200 status code: %d", resp.StatusCode)
}
var apiResp geoJSResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode geojs.io response: %w", err)
}
// 检查国家代码是否为空,因为 geojs 对无效/私有IP可能返回200 OK但内容为空
if apiResp.CountryCode == "" {
return nil, fmt.Errorf("geojs.io returned empty geo info for ip: %s", ip.String())
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
}, nil
}
// UpdateDatabase 对于 geojs.io 是一个空操作,因为它是一个 Web 服务。
func (s *GeoJSService) UpdateDatabase() error {
return nil
}
// Close 对于 geojs.io 是一个空操作。
func (s *GeoJSService) Close() error {
return nil
}
+80
View File
@@ -0,0 +1,80 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// IPAPIService 使用 ip-api.com 服务实现 GeoIPService 接口。
type IPAPIService struct {
Client *http.Client
}
// ipAPIResponse 定义了 ip-api.com 服务返回的 JSON 响应的结构。
type ipAPIResponse struct {
Status string `json:"status"`
Message string `json:"message"` // 当 status 为 fail 时出现
Country string `json:"country"`
CountryCode string `json:"countryCode"`
Region string `json:"region"`
RegionName string `json:"regionName"`
City string `json:"city"`
Zip string `json:"zip"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Timezone string `json:"timezone"`
ISP string `json:"isp"`
Org string `json:"org"`
As string `json:"as"`
Query string `json:"query"`
}
// NewIPAPIService 创建并返回一个 IPAPIService 的新实例。
func NewIPAPIService() (*IPAPIService, error) {
return &IPAPIService{
Client: &http.Client{
Timeout: 5 * time.Second, // 设置请求超时
},
}, nil
}
// GetGeoInfo 使用 ip-api.com 服务检索给定 IP 地址的地理位置信息。
func (s *IPAPIService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// API URL, 使用 fields 参数来仅请求需要的字段
apiURL := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,countryCode", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from ip-api.com: %w", err)
}
defer resp.Body.Close()
var apiResp ipAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode ip-api.com response: %w", err)
}
if apiResp.Status != "success" {
return nil, fmt.Errorf("ip-api.com returned an error: %s", apiResp.Message)
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
}, nil
}
// UpdateDatabase 对于 ip-api.com 是一个空操作,因为它是一个 Web 服务。
func (s *IPAPIService) UpdateDatabase() error {
// 无需执行任何操作,因为数据由外部服务提供
return nil
}
// Close 对于 ip-api.com 是一个空操作,因为没有需要关闭的持久连接。
func (s *IPAPIService) Close() error {
// 无需执行任何操作
return nil
}
+86
View File
@@ -0,0 +1,86 @@
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
}
// 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
}
+176
View File
@@ -0,0 +1,176 @@
package geoip // 与 geoip.go 保持相同的包名,表示它们是同一个包的组成部分
import (
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath" // 新增导入,用于处理文件路径
"sync"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/oschwald/maxminddb-golang"
)
// GeoIpUrl 是 MaxMind 数据库的下载地址。
var GeoIpUrl = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
// GeoIpFilePath 是本地存储 MaxMind 数据库的路径。
var GeoIpFilePath = "./data/GeoLite2-Country.mmdb"
// GeoIpRecord 结构体定义了 MaxMind 数据库查询结果的原始结构。
// 它是 MaxMind 库特有的,用于从 .mmdb 文件中解析数据。
type GeoIpRecord struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
}
// MaxMindGeoIPService 是 GeoIPService 接口的一个具体实现,
// 它使用 MaxMind 数据库作为后端。
type MaxMindGeoIPService struct {
// maxMindDBReader 内部持有的 MaxMind 数据库读取器实例。
maxMindDBReader *maxminddb.Reader
// dbFilePath 是 MaxMind 数据库文件的路径。
dbFilePath string
// mu 用于保护对 maxMindDBReader 的并发访问,确保线程安全。
mu sync.RWMutex
}
// NewMaxMindGeoIPService 创建并返回一个 MaxMindGeoIPService 实例。
// 它负责初始化服务,包括尝试加载或下载数据库。
func NewMaxMindGeoIPService() (*MaxMindGeoIPService, error) {
dbFilePath := GeoIpFilePath
service := &MaxMindGeoIPService{
dbFilePath: dbFilePath,
}
// 确保数据目录存在
if err := os.MkdirAll(filepath.Dir(dbFilePath), os.ModePerm); err != nil {
logOperation.Log("", "", "Failed to create data directory for MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to create data directory for MaxMind database: %w", err)
}
// 检查数据库文件是否存在,如果不存在则尝试下载
if _, err := os.Stat(dbFilePath); os.IsNotExist(err) {
if err := service.UpdateDatabase(); err != nil {
logOperation.Log("", "", "Failed to download initial MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to download initial MaxMind database: %w", err)
}
}
// 初始化或重新加载 MaxMind 数据库。
if err := service.initialize(); err != nil {
logOperation.Log("", "", "Failed to initialize MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to initialize MaxMind database: %w", err)
}
return service, nil
}
// initialize 初始化或重新加载 MaxMind 数据库。
// 这是一个内部方法,供 NewMaxMindGeoIPService 和 UpdateDatabase 调用。
// 它会关闭现有连接(如果存在)并重新打开数据库文件。
func (s *MaxMindGeoIPService) initialize() error {
s.mu.Lock()
defer s.mu.Unlock()
// 如果已存在数据库读取器,则先关闭它。
if s.maxMindDBReader != nil {
s.maxMindDBReader.Close()
s.maxMindDBReader = nil
}
// 尝试打开新的数据库文件。
reader, err := maxminddb.Open(s.dbFilePath)
if err != nil {
return fmt.Errorf("error opening MaxMind database at %s: %w", s.dbFilePath, err)
}
s.maxMindDBReader = reader
return nil
}
// GetGeoInfo 根据 IP 地址获取 MaxMind 的地理位置信息。
// 它查询 MaxMind 数据库并将其特有的 GeoIpRecord 转换为通用的 GeoInfo 结构体。
func (s *MaxMindGeoIPService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.maxMindDBReader == nil {
return nil, fmt.Errorf("MaxMind database is not initialized or failed to open")
}
if ip == nil {
return nil, fmt.Errorf("IP address cannot be nil")
}
var record GeoIpRecord // 使用原始的 GeoIpRecord 结构体来接收查询结果
err := s.maxMindDBReader.Lookup(ip, &record)
if err != nil {
// 返回错误,但避免直接返回 maxminddb 库的内部错误,提供更友好的信息
return nil, fmt.Errorf("error looking up IP %s in MaxMind database: %w", ip.String(), err)
}
// 将 MaxMind 的特定结构体转换为通用的 GeoInfo 结构体
geoInfo := &GeoInfo{
ISOCode: record.Country.ISOCode,
// 尝试获取英文国家名称,如果不存在则使用 ISO 代码作为备用
Name: record.Country.Names["en"],
}
if geoInfo.Name == "" && geoInfo.ISOCode != "" {
geoInfo.Name = geoInfo.ISOCode // 如果没有英文名称,回退到 ISO 代码
}
return geoInfo, nil
}
// UpdateDatabase 实现了 GeoIPService 接口的 UpdateDatabase 方法。
// 它会下载最新的 GeoLite2-Country.mmdb 文件并重新加载数据库。
func (s *MaxMindGeoIPService) UpdateDatabase() error {
s.mu.Lock() // 获取写锁,确保更新过程的互斥性
resp, err := http.Get(GeoIpUrl) // GeoIpUrl 是预定义的 MaxMind 数据库下载地址
if err != nil {
return fmt.Errorf("failed to initiate MaxMind database download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download MaxMind database: HTTP status %s", resp.Status)
}
// 确保数据目录存在(NewMaxMindGeoIPService 已处理,但这里再次确保以防直接调用)
if err := os.MkdirAll(filepath.Dir(s.dbFilePath), os.ModePerm); err != nil {
return fmt.Errorf("failed to create data directory for MaxMind database update: %w", err)
}
out, err := os.Create(s.dbFilePath) // 创建或覆盖本地数据库文件
if err != nil {
return fmt.Errorf("failed to create MaxMind database file at %s: %w", s.dbFilePath, err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body) // 将下载内容写入文件
if err != nil {
return fmt.Errorf("failed to write MaxMind database file: %w", err)
}
s.mu.Unlock() // initialize 方法需要在解锁后调用,以避免死锁
// 重新加载数据库以使用新下载的文件
return s.initialize()
}
// Close 实现了 GeoIPService 接口的 Close 方法。
// 它关闭 MaxMind 数据库读取器,释放文件句柄和其他资源。
func (s *MaxMindGeoIPService) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.maxMindDBReader != nil {
err := s.maxMindDBReader.Close()
s.maxMindDBReader = nil // 清空读取器实例
if err != nil {
return fmt.Errorf("error closing MaxMind database: %w", err)
}
}
log.Println("MaxMind GeoIP service closed.")
return nil
}
+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")
}
+26 -10
View File
@@ -1,22 +1,38 @@
package messageSender
import (
"errors"
"github.com/komari-monitor/komari/database/config"
)
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 {
return CurrentProvider.SendTextMessage(message, title)
}
+18 -22
View File
@@ -10,27 +10,36 @@ import (
"github.com/komari-monitor/komari/database/config"
)
func TelegramSendMessage(message, msgType string) error {
// TelegramMessageSender implements the MessageSender interface for Telegram.
type TelegramMessageSender struct{}
// 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)
}
var lastErr error
for i := 0; i < 3; i++ {
for i := 0; i < 3; i++ { // Retry mechanism
conf, err := config.Get()
if err != nil {
lastErr = err
continue
}
if message == "" {
return errors.New("telegram is disabled or message is empty")
if fullMessage == "" {
return errors.New("telegram is disabled or message is empty") // Assuming disabled if 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)
}
data.Set("text", fullMessage)
data.Set("parse_mode", "HTML") // Use HTML for basic formatting if title is present
resp, err := http.PostForm(endpoint, data)
if err != nil {
lastErr = fmt.Errorf("failed to send message: %v", err)
@@ -60,17 +69,4 @@ func TelegramSendMessage(message, msgType string) error {
return nil
}
return lastErr
}
// SendTextMessage sends a text message via Telegram API
func TelegramSendTextMessage(message string) error {
return TelegramSendMessage(message, "text")
}
// 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")
}
}
+81
View File
@@ -0,0 +1,81 @@
package notification
import (
"fmt"
"math"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/messageSender"
)
func CheckExpireScheduledWork() {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location()) // UTC 9AM = CST 17PM
if now.After(next) {
next = next.Add(24 * time.Hour)
}
duration := next.Sub(now)
time.Sleep(duration)
cfg, err := config.Get()
if err != nil {
time.Sleep(time.Second)
continue
}
if !cfg.ExpireNotificationEnabled {
time.Sleep(time.Second)
continue
}
notificationLeadDays := cfg.ExpireNotificationLeadDays
clients_all, err := clients.GetAllClientBasicInfo()
if err != nil {
time.Sleep(time.Second)
continue
}
type clientToExpireInfo struct {
Name string
DaysLeft int
}
var clientLeadToExpire []clientToExpireInfo
checkTime := time.Now()
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
if clientExpireTime.Before(checkTime) {
continue
}
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
if len(clientLeadToExpire) > 0 {
message := "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, "Komari Expiration Notification")
}
// 等待1秒,防止多次触发
time.Sleep(time.Second)
}
}
+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)