mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 00:30:54 +00:00
Compare commits
16
Commits
0.1.1
..
0.1.2-fix2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
862657ea86 | ||
|
|
70b519a345 | ||
|
|
6600e4014d | ||
|
|
b8a6f18a77 | ||
|
|
03e08cdfbb | ||
|
|
b07f7345ee | ||
|
|
e85996178a | ||
|
|
5547fcd067 | ||
|
|
aef4344997 | ||
|
|
061197cd77 | ||
|
|
c00d1d0cb2 | ||
|
|
b6a7511e33 | ||
|
|
13450a50eb | ||
|
|
eb883ccc6f | ||
|
|
2ae9b3516c | ||
|
|
7dc2a0294e |
No files matched your search
@@ -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: |
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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 自动调用
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ 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"
|
||||
@@ -54,6 +55,7 @@ var ServerCmd = &cobra.Command{
|
||||
}
|
||||
go geoip.InitGeoIp()
|
||||
go DoScheduledWork()
|
||||
go messageSender.Initialize()
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
@@ -68,6 +70,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 +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")
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'"`
|
||||
|
||||
@@ -89,6 +89,18 @@ 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{}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ 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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+1
-1
@@ -128,7 +128,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 +154,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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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秒,防止多次触发
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user