Compare commits

..
44 Commits
Author SHA1 Message Date
Akizon 56dd512d3c Merge pull request #157 from imlonghao/feat/5years
feat: 增加五年期的账单周期
2025-08-02 23:40:31 +08:00
Akizon 16f11b052a Merge pull request #158 from JohnsonRan/kernel-version
feat: add kernel_version field to Client model
2025-08-02 23:39:56 +08:00
JohnsonRan 06bae7efcb feat: add kernel_version field to Client model 2025-08-02 22:52:17 +08:00
imlonghao 39b017620f feat: 增加五年期的账单周期 2025-08-02 22:21:10 +08:00
Akizon77 45e8a3a530 Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-01 14:28:27 +00:00
Akizon77 8dd64a9fa3 fix: 更新OAuth回调中文案消息 2025-08-01 14:04:19 +00:00
Akizon c1e8b0779f Merge pull request #155 from komari-monitor/dev
feat: #149 支持超级群组话题
2025-08-01 21:53:47 +08:00
Akizon77 d16daf1862 feat: #149 支持超级群组话题 2025-08-01 13:45:06 +00:00
Akizon77 491b092ac1 feat: 添加Help字段到Item结构 2025-08-01 13:17:26 +00:00
Akizon77 e2304c6b1b refactor: 重构messageSender接口 2025-08-01 09:05:43 +00:00
Akizon77 ea52f61a2c Merge branch 'dev' 2025-08-01 06:53:12 +00:00
Akizon77 3db6edcc3d fix: 保存第三方登陆后会自动重载 2025-08-01 06:52:39 +00:00
Akizon77 2045dd3f30 Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-01 06:33:01 +00:00
Akizon77 7a37c93a02 fix: 修复私有站点中对非/api路径的放行逻辑 2025-08-01 06:32:26 +00:00
Akizon 1d49300f8c Merge pull request #154 from komari-monitor/dev
fix: OAuth校验state失效、打开私有化后客户点连接401的问题
2025-08-01 14:24:10 +08:00
Akizon77 f5641df0d9 fix: OAuth校验state失效 2025-08-01 06:22:10 +00:00
Akizon77 b9c1e58703 fix: /api/clients 绕过 2025-08-01 06:18:18 +00:00
Akizon77 22b9fada55 fix: 打开私有化后客户点连接401的问题 2025-07-31 16:07:00 +00:00
Akizon d34b286c4f Merge pull request #152 from komari-monitor/dev
refactor: 重构OAuth
2025-07-31 16:42:02 +08:00
Akizon77 6dab3c8450 refactor: 重写oauth逻辑 2025-07-31 08:23:34 +00:00
Akizon77 32dd5c9414 feat: OIDC接口 2025-07-31 07:24:00 +00:00
Akizon77 2f09d28b94 feat: 添加OIDC接口 2025-07-31 06:08:09 +00:00
Akizon77 77ce900bbf wip: 添加通用OIDC提供者实现及相关注册逻辑 2025-07-31 04:51:27 +00:00
Akizon77 0675634b27 wip: OIDC接口 2025-07-30 09:45:14 +00:00
Akizon77 74db186ab3 feat: 更新备份恢复成功消息并添加服务重启逻辑 2025-07-30 07:10:56 +00:00
Akizon 1a5bc31b9b Merge pull request #151 from komari-monitor/dev
feat: #143 添加全站私有化
2025-07-30 13:37:54 +08:00
Akizon77 f6a94db702 Merge branch 'main' into dev 2025-07-29 08:11:39 +00:00
Akizon77 6213ce7556 feat: #143 添加全站私有化 2025-07-29 08:11:27 +00:00
Akizon77 ff91e548b8 docs: 添加英语、日文和繁体中文的 README 文件 2025-07-29 07:28:26 +00:00
Akizon77 6d59070184 Merge branch 'main' into dev 2025-07-28 08:21:22 +00:00
Akizon77 c8126e551f refactor: 将 UTCTime 类型替换为 LocalTime 2025-07-28 08:20:42 +00:00
Akizon77 589eae0d3f ci: 更新清理旧包的工作流配置 2025-07-28 04:20:26 +00:00
Akizon77 8e25bc9ce3 ci: 删除没有标签的版本 2025-07-28 04:15:24 +00:00
Akizon77 9aa4dcc206 ci: 更新 Docker 发布工作流 2025-07-28 04:12:23 +00:00
Akizon77 b2b5966699 fix: 修改 Ping 记录时间格式为 RFC3339 2025-07-26 07:20:34 +00:00
Akizon77 e9c7e0031c fix: #146 优化 Ping 记录的输出结构 2025-07-26 07:19:31 +00:00
Akizon77 1e3d29ffdb Merge branch 'main' of https://github.com/komari-monitor/komari 2025-07-26 07:10:15 +00:00
Akizon77 84670ac231 feat: 恢复备份添加锁、添加备份标记文件并在上传时验证 2025-07-26 06:56:02 +00:00
Akizon 6b17c5c498 Update README.md 2025-07-26 14:11:21 +08:00
Akizon eed9820ce6 Merge pull request #148 from dbedu/main
feat: 备份文件恢复
2025-07-26 10:06:35 +08:00
dbedu 858d914ae3 新功能: 新增备份文件恢复 2025-07-26 00:46:07 +08:00
Akizon be7c3812db docs: 更新文档链接 2025-07-25 01:00:17 +08:00
Akizon 44c65f1eb8 docs: 容器云标注价格 2025-07-25 00:36:50 +08:00
Akizon dd742c1038 docs: 添加雨云一键部署 2025-07-25 00:16:53 +08:00
55 changed files with 2259 additions and 576 deletions

No files matched your search

+29 -11
View File
@@ -20,23 +20,41 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
package_type: 'container',
package_name: process.env.GITHUB_REPOSITORY.split('/')[1]
});
// 分页获取所有包版本
const allVersions = [];
let page = 1;
let fetched;
const packageName = process.env.GITHUB_REPOSITORY.split('/')[1];
do {
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
package_type: 'container',
package_name: packageName,
page,
per_page: 100
});
fetched = packages.length;
allVersions.push(...packages);
page++;
} while (fetched === 100);
// Identify versions with only a single SHA tag (format: sha-<7 hexadecimal characters>)
const shaOnlyVersions = packages.filter(pkg => {
// 筛选仅有 SHA/sha256 标签或无标签的版本
const shaOrNoTagVersions = allVersions.filter(pkg => {
const tags = pkg.metadata.container.tags;
return tags.length === 1 && tags[0].match(/^sha-[0-9a-f]{7}$/);
return (
(tags.length === 1 && (
tags[0].match(/^sha-[0-9a-f]{7}$/) ||
tags[0].match(/^sha256:[0-9a-f]{64}$/)
)) ||
tags.length === 0
);
});
// Delete the SHA-only versions
for (const version of shaOnlyVersions) {
// 删除这些版本
for (const version of shaOrNoTagVersions) {
await github.rest.packages.deletePackageVersionForAuthenticatedUser({
package_type: 'container',
package_name: process.env.GITHUB_REPOSITORY.split('/')[1],
package_name: packageName,
package_version_id: version.id
});
console.log(`Deleted package version ${version.id} with SHA tag ${version.metadata.container.tags[0]}`);
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
}
+1 -1
View File
@@ -1,4 +1,4 @@
name: Publish Docker Image
name: Publish Docker Image on Main
on:
push:
+49 -39
View File
@@ -4,35 +4,42 @@
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
[简体中文](./docs/README_zh.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.md)
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
[Documentation](https://komari-document.pages.dev/) | [Telegram Group](https://t.me/komari_monitor)
## 快速开始
## Features
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
- **Self-hosted**: Complete control over data privacy, easy to deploy.
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
### 0. 容器云一键部署
## Quick Start
### 0. One-click Deployment with Cloud Hosting
- Rainyun - CNY 4.5/month
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/month
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
### 1. Use the One-click Install Script
Suitable for distributions using systemd (Ubuntu, Debian...).
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
### 2. Docker Deployment
1. Create a data directory:
```bash
mkdir -p ./data
```
2. 运行 Docker 容器:
2. Run the Docker container:
```bash
docker run -d \
-p 25774:25774 \
@@ -40,70 +47,73 @@ sudo ./install-komari.sh
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看默认账号和密码:
3. View the default username and password:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
4. Access `http://<your_server_ip>:25774` in your browser.
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
### 3. Binary File Deployment
1. Visit Komari's [GitHub Release page](https://github.com/komari-monitor/komari/releases) to download the latest binary for your operating system.
2. Run Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
3. Access `http://<your_server_ip>:25774` in your browser. The default port is `25774`.
4. The default username and password can be found in the startup logs or set via the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
### 手工构建
### Manual Build
#### 依赖
#### Dependencies
- Go 1.18+ 和 Node.js 20+(手工构建)
- Go 1.18+ and Node.js 20+ (for manual build)
1. 构建前端静态文件:
1. Build the frontend static files:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 构建后端:
2. Build the backend:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
```bash
go build -o komari
```
4. 运行:
4. Run:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
The default listening port is `25774`. Access `http://localhost:25774`.
## 前端开发指南
[Komari 主题开发指南 | Komari](https://komari-monitor.github.io/komari-document/dev/theme.html)
## Frontend Development Guide
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-monitor.github.io/komari-document/dev/agent.html)
[Translate Komari on Crowdin](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 贡献
欢迎提交 Issue 或 Pull Request!
## Client Agent Development Guide
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
## 鸣谢
## Contributing
Issues and Pull Requests are welcome!
## Acknowledgements
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感谢我自己能这么闲
- 提交PR、制作主题的各位开发者
- Thanks to myself for being so idle
- All the developers who submitted PRs and created themes
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/gin-gonic/gin"
)
var (
publicPaths = []string{
"/ping",
"/api/public",
"/api/login",
"/api/me",
"/api/oauth",
"/api/oauth_callback",
"/api/version",
"/api/admin", // 由AdminAuthMiddleware处理
"/api/clients/", // 由TokenAuthMiddleware处理
}
)
func PrivateSiteMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 如果是公开的路径,直接放行
for _, path := range publicPaths {
if len(c.Request.URL.Path) >= len(path) && c.Request.URL.Path[:len(path)] == path {
c.Next()
return
}
}
// 如果不是 /api,直接放行
if len(c.Request.URL.Path) < 4 || c.Request.URL.Path[:4] != "/api" {
c.Next()
return
}
conf, err := config.Get()
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
return
}
// 验证私有, 如果不是私有站点,直接放行
if !conf.PrivateSite {
c.Next()
return
}
// 如果是私有站点,检查是否有 session
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
return
}
_, err = accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
c.Next()
}
}
+18
View File
@@ -135,5 +135,23 @@ func DownloadBackup(c *gin.Context) {
log.Printf("Database file '%s' is within './data', skipping explicit addition.\n", dbFilePath)
}
// 添加备份标记文件
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format("2006-01-02 15:04:05")
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: "komari-backup-markup",
Method: zip.Deflate,
Modified: time.Now(),
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
return
}
_, err = markupWriter.Write([]byte(markupContent))
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
return
}
// zipWriter.Close() 由 defer 自动调用
}
+63
View File
@@ -0,0 +1,63 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func GetMessageSenderProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetMessageSenderConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置项模板
providers := factory.GetSenderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No message sender providers found")
return
}
api.RespondSuccess(c, providers)
}
func SetMessageSenderProvider(c *gin.Context) {
var senderConfig models.MessageSenderProvider
if err := c.ShouldBindJSON(&senderConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if senderConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(senderConfig.Name)
if !exists {
api.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
return
}
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
return
}
cfg, _ := config.Get()
// 正在使用,重载
if cfg.NotificationMethod == senderConfig.Name {
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
if err != nil {
api.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
return
}
}
api.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
}
+57
View File
@@ -3,7 +3,12 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/utils/oauth/factory"
)
func BindingExternalAccount(c *gin.Context) {
@@ -33,3 +38,55 @@ func UnbindExternalAccount(c *gin.Context) {
api.RespondSuccess(c, nil)
}
func GetOidcProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetOidcConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置
providers := factory.GetProviderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No OIDC providers found")
return
}
api.RespondSuccess(c, providers)
}
func SetOidcProvider(c *gin.Context) {
var oidcConfig models.OidcProvider
if err := c.ShouldBindJSON(&oidcConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if oidcConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(oidcConfig.Name)
if !exists {
api.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
return
}
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
return
}
cfg, _ := config.Get()
// 正在使用,重载
if cfg.OAuthProvider == oidcConfig.Name {
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
if err != nil {
api.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
return
}
}
api.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
}
+173
View File
@@ -0,0 +1,173 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
)
// 只有一个备份恢复操作在进行
var restoreMutex sync.Mutex
// UploadBackup 用于接收上传的备份文件并将其内容恢复到原始位置
func UploadBackup(c *gin.Context) {
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
if !restoreMutex.TryLock() {
api.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
return
}
defer restoreMutex.Unlock()
// 获取上传的文件
file, header, err := c.Request.FormFile("backup")
if err != nil {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
return
}
defer file.Close()
// 检查文件是否为zip格式
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
api.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
return
}
// 创建临时文件保存上传的zip
tempFile, err := os.CreateTemp("", "backup-*.zip")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
return
}
tempFilePath := tempFile.Name()
defer os.Remove(tempFilePath) // 确保临时文件最终被删除
// 将上传的文件内容复制到临时文件
_, err = io.Copy(tempFile, file)
if err != nil {
tempFile.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
return
}
tempFile.Close() // 关闭文件以便后续操作
// 打开zip文件准备解压
zipReader, err := zip.OpenReader(tempFilePath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
return
}
defer zipReader.Close()
// 检查是否包含备份标记文件
hasMarkupFile := false
for _, zipFile := range zipReader.File {
if zipFile.Name == "komari-backup-markup" {
hasMarkupFile = true
break
}
}
if !hasMarkupFile {
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
return
}
// 确保data目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
return
}
// 获取数据库文件名
dbFileName := filepath.Base(flags.DatabaseFile)
// 解压文件
for _, zipFile := range zipReader.File {
// 检查文件路径是否安全(防止路径遍历攻击)
filePath := zipFile.Name
if strings.Contains(filePath, "..") {
log.Printf("Potentially unsafe path in zip: %s, skipping", filePath)
continue
}
// 跳过备份标记文件
if filePath == "komari-backup-markup" {
continue
}
// 确定目标路径
var destPath string
if filePath == dbFileName {
// 如果是数据库文件,恢复到数据库文件路径
destPath = flags.DatabaseFile
} else {
// 其他文件恢复到data目录
destPath = filepath.Join("./data", filePath)
}
// 如果是目录,创建目录
if zipFile.FileInfo().IsDir() {
if err := os.MkdirAll(destPath, 0755); err != nil {
log.Printf("Error creating directory %s: %v", destPath, err)
}
continue
}
// 确保目标文件的目录存在
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
log.Printf("Error creating directory %s: %v", destDir, err)
continue
}
// 打开zip中的文件
srcFile, err := zipFile.Open()
if err != nil {
log.Printf("Error opening file from zip %s: %v", filePath, err)
continue
}
// 创建目标文件
destFile, err := os.Create(destPath)
if err != nil {
srcFile.Close()
log.Printf("Error creating file %s: %v", destPath, err)
continue
}
// 复制内容
_, err = io.Copy(destFile, srcFile)
srcFile.Close()
destFile.Close()
if err != nil {
log.Printf("Error extracting file %s: %v", destPath, err)
continue
}
// 保持原始文件的修改时间
if err := os.Chtimes(destPath, zipFile.Modified, zipFile.Modified); err != nil {
log.Printf("Error setting file time for %s: %v", destPath, err)
}
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Backup restored successfully. The service will restart shortly.",
})
go func() {
log.Println("Backup restored, restarting service in 2 seconds...")
time.Sleep(2 * time.Second)
os.Exit(0)
}()
}
+11 -12
View File
@@ -18,7 +18,7 @@ func OAuth(c *gin.Context) {
return
}
authURL, state := oauth.GetOAuthUrl()
authURL, state := oauth.CurrentProvider().GetAuthorizationURL()
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
@@ -35,21 +35,20 @@ func OAuthCallback(c *gin.Context) {
return
}
// 获取code
code := c.Query("code")
if code == "" {
c.JSON(400, gin.H{"status": "error", "error": "No code provided"})
return
queries := make(map[string]string)
for key, values := range c.Request.URL.Query() {
if len(values) > 0 {
queries[key] = values[0]
}
}
githubUser, err := oauth.OAuthCallback(code, state)
oidcUser, err := oauth.CurrentProvider().OnCallback(c.Request.Context(), state, queries)
if err != nil {
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
return
}
// 使用GitHub ID作为SSO ID
sso_id := fmt.Sprintf("github_%d", githubUser.ID)
// ID作为SSO ID
sso_id := fmt.Sprintf("%s_%s", oauth.CurrentProvider().GetName(), oidcUser.UserId)
// 如果cookie中有binding_external_account,说明是绑定外部账号
// 否则是登录
@@ -78,7 +77,7 @@ func OAuthCallback(c *gin.Context) {
if err != nil {
c.JSON(401, gin.H{
"status": "error",
"message": "please log in and bind your GitHub account first.",
"message": "please log in and bind your external account first.",
})
return
}
@@ -93,5 +92,5 @@ func OAuthCallback(c *gin.Context) {
// 设置cookie并返回
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
auditlog.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
c.Redirect(302, "/manage")
c.Redirect(302, "/admin")
}
+2
View File
@@ -18,11 +18,13 @@ func GetPublicSettings(c *gin.Context) {
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
"private_site": cst.PrivateSite,
})
}
+21 -4
View File
@@ -42,6 +42,16 @@ func GetRecordsByUUID(c *gin.Context) {
func GetPingRecords(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
type RecordsResp struct {
TaskId uint `json:"task_id"`
Time string `json:"time"`
Value int `json:"value"`
}
type Resp struct {
Count int `json:"count"`
Records []RecordsResp `json:"records"`
Tasks []gin.H `json:"tasks,omitempty"` // 任务列表
}
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
@@ -63,9 +73,16 @@ func GetPingRecords(c *gin.Context) {
return
}
response := gin.H{
"records": records,
"count": len(records),
response := &Resp{
Count: len(records),
Records: []RecordsResp{},
}
for _, r := range records {
response.Records = append(response.Records, RecordsResp{
TaskId: r.TaskId,
Time: r.Time.ToTime().Format(time.RFC3339),
Value: r.Value,
})
}
if len(records) > 0 {
@@ -103,7 +120,7 @@ func GetPingRecords(c *gin.Context) {
})
}
}
response["tasks"] = tasksList
response.Tasks = tasksList
}
api.RespondSuccess(c, response)
+49 -7
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"fmt"
"log"
"net/http"
"os"
@@ -21,6 +22,7 @@ import (
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
@@ -35,6 +37,7 @@ import (
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/ws"
"github.com/spf13/cobra"
)
@@ -72,10 +75,43 @@ func RunServer() {
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
conf, err := config.Get()
if err != nil {
log.Fatal(err)
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
// oidcInit
oidcProvider, err := database.GetOidcConfigByName(conf.OAuthProvider)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
}
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
if err != nil {
log.Printf("Failed to load OIDC provider: %v", err)
} else {
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
}
config.Subscribe(func(event config.ConfigEvent) {
if event.New.OAuthProvider != event.Old.OAuthProvider {
oidcProvider, err := database.GetOidcConfigByName(event.New.OAuthProvider)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
} else {
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
}
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
if err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
messageSender.Initialize()
}
})
// 初始化 cloudflared
if strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
if err != nil {
@@ -86,10 +122,7 @@ func RunServer() {
r := gin.Default()
// 动态 CORS 中间件
conf, err := config.Get()
if err != nil {
log.Fatal(err)
}
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
DynamicCorsEnabled = event.New.AllowCors
@@ -116,6 +149,8 @@ func RunServer() {
c.Next()
})
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
@@ -153,6 +188,7 @@ func RunServer() {
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
@@ -178,9 +214,15 @@ func RunServer() {
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
adminAuthrized.GET("/settings", admin.GetSettings)
adminAuthrized.POST("/settings", admin.EditSettings)
settingsGroup := adminAuthrized.Group("/settings")
{
settingsGroup.GET("/", admin.GetSettings)
settingsGroup.POST("/", admin.EditSettings)
settingsGroup.POST("/oidc", admin.SetOidcProvider)
settingsGroup.GET("/oidc", admin.GetOidcProvider)
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
}
// themes
themeGroup := adminAuthrized.Group("/theme")
{
+1 -22
View File
@@ -32,9 +32,6 @@ func Get() (models.Config, error) {
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "ip-api",
EmailPort: 587,
EmailUseSSL: true,
EmailReceiver: "", // Default empty
UpdatedAt: models.FromTime(time.Now()),
CreatedAt: models.FromTime(time.Now()),
}
@@ -54,26 +51,8 @@ func Save(cst models.Config) error {
// Only one records
cst.ID = 1
cst.UpdatedAt = models.FromTime(time.Now())
// Do not update CreatedAt
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
Select("sitename",
"description",
"allow_cors",
"geo_ip_enabled",
"geo_ip_provider",
"o_auth_client_id",
"o_auth_client_secret",
"o_auth_enabled",
"custom_head",
"email_enabled",
"email_host",
"email_port",
"email_username",
"email_password",
"email_sender",
"email_use_ssl",
"email_receiver",
"updated_at").
Select("*").
Updates(cst).Error; err != nil {
return err
}
+124
View File
@@ -1,6 +1,7 @@
package dbcore
import (
"encoding/json"
"fmt"
"log"
"sync"
@@ -79,6 +80,127 @@ func MergeDatabase(db *gorm.DB) {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
db.Migrator().DropTable(&models.LoadNotification{})
}
if !db.Migrator().HasTable(&models.OidcProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Merge OidcProvider table....")
var config struct {
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for OIDC provider migration:", err)
}
db.AutoMigrate(&models.OidcProvider{})
j, err := json.Marshal(&map[string]string{
"client_id": config.OAuthClientID,
"client_secret": config.OAuthClientSecret,
})
if err != nil {
log.Println("Failed to marshal OIDC provider config:", err)
return
}
db.Save(&models.OidcProvider{
Name: "github",
Addition: string(j),
})
db.AutoMigrate(&models.Config{})
db.Model(&models.Config{}).Where("id = 1").Update("o_auth_provider", "github")
}
if !db.Migrator().HasTable(&models.MessageSenderProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Migrate MessageSender configuration....")
var config struct {
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255)"`
EmailHost string `json:"email_host" gorm:"type:varchar(255)"`
EmailPort int `json:"email_port" gorm:"type:int"`
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"`
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"`
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"`
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"`
EmailUseSSL bool `json:"email_use_ssl" gorm:"type:boolean"`
NotificationMethod string `json:"notification_method" gorm:"type:varchar(50)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for MessageSender migration:", err)
}
db.AutoMigrate(&models.MessageSenderProvider{})
// 迁移Telegram配置
if config.NotificationMethod == "telegram" && config.TelegramBotToken != "" {
telegramConfig := map[string]interface{}{
"bot_token": config.TelegramBotToken,
"chat_id": config.TelegramChatID,
"endpoint": config.TelegramEndpoint,
}
if telegramConfig["endpoint"] == "" {
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
}
telegramConfigJSON, err := json.Marshal(telegramConfig)
if err != nil {
log.Println("Failed to marshal Telegram config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "telegram",
Addition: string(telegramConfigJSON),
})
}
}
// 迁移Email配置
if config.NotificationMethod == "email" && config.EmailHost != "" {
emailConfig := map[string]interface{}{
"host": config.EmailHost,
"port": config.EmailPort,
"username": config.EmailUsername,
"password": config.EmailPassword,
"sender": config.EmailSender,
"receiver": config.EmailReceiver,
"use_ssl": config.EmailUseSSL,
}
emailConfigJSON, err := json.Marshal(emailConfig)
if err != nil {
log.Println("Failed to marshal Email config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "email",
Addition: string(emailConfigJSON),
})
}
}
// 删除旧的配置字段
if db.Migrator().HasColumn(&models.Config{}, "telegram_bot_token") {
db.Migrator().DropColumn(&models.Config{}, "telegram_bot_token")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_chat_id") {
db.Migrator().DropColumn(&models.Config{}, "telegram_chat_id")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_endpoint") {
db.Migrator().DropColumn(&models.Config{}, "telegram_endpoint")
}
if db.Migrator().HasColumn(&models.Config{}, "email_host") {
db.Migrator().DropColumn(&models.Config{}, "email_host")
}
if db.Migrator().HasColumn(&models.Config{}, "email_port") {
db.Migrator().DropColumn(&models.Config{}, "email_port")
}
if db.Migrator().HasColumn(&models.Config{}, "email_username") {
db.Migrator().DropColumn(&models.Config{}, "email_username")
}
if db.Migrator().HasColumn(&models.Config{}, "email_password") {
db.Migrator().DropColumn(&models.Config{}, "email_password")
}
if db.Migrator().HasColumn(&models.Config{}, "email_sender") {
db.Migrator().DropColumn(&models.Config{}, "email_sender")
}
if db.Migrator().HasColumn(&models.Config{}, "email_receiver") {
db.Migrator().DropColumn(&models.Config{}, "email_receiver")
}
if db.Migrator().HasColumn(&models.Config{}, "email_use_ssl") {
db.Migrator().DropColumn(&models.Config{}, "email_use_ssl")
}
}
}
var (
@@ -140,6 +262,8 @@ func GetDBInstance() *gorm.DB {
&models.OfflineNotification{},
&models.PingRecord{},
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
+29
View File
@@ -0,0 +1,29 @@
package database
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetAllMessageSenderConfigs() []models.MessageSenderProvider {
db := dbcore.GetDBInstance()
var result []models.MessageSenderProvider
if err := db.Find(&result).Error; err != nil {
return nil
}
return result
}
func GetMessageSenderConfigByName(name string) (*models.MessageSenderProvider, error) {
db := dbcore.GetDBInstance()
var config models.MessageSenderProvider
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
func SaveMessageSenderConfig(config *models.MessageSenderProvider) error {
db := dbcore.GetDBInstance()
return db.Save(config).Error
}
+71 -96
View File
@@ -10,137 +10,112 @@ import (
"time"
)
// UTCTime 是一个自定义类型,用于在 GORM 中处理 time.Time。
// 它强制所有时间都以指定时区(或 UTC)的字符串格式存取。
type UTCTime time.Time
// LocalTime is a custom time type for GORM to handle time.Time correctly.
// It ensures that all times are stored and retrieved based on the application's configured timezone.
type LocalTime time.Time
// Value 实现 driver.Valuer 接口,将 UTCTime 转换为数据库可以识别的 TEXT 类型。
// 所有时间在存储到数据库前,都转换为 UTC 并以标准格式存储。
func (t UTCTime) Value() (driver.Value, error) {
// Value implements the driver.Valuer interface.
// It converts UTCTime to a TEXT format that the database understands.
// The time is formatted as a string in the application's local timezone, without timezone information.
func (t LocalTime) Value() (driver.Value, error) {
if time.Time(t).IsZero() {
return nil, nil // GORM 默认会将零值时间存储为 NULL,这里保持一致
return nil, nil
}
// 将时间转换为应用程序指定的时区,并格式化为不带时区信息的字符串。
// 这样数据库中存储的是 'YYYY-MM-DD HH:MM:SS.NNNNNNN' 格式。
// 注意:之前你遇到的格式是 "2025-06-14 23:51:17.3022328+08:00"
// 这种格式带有微秒和时区偏移。为了兼容旧数据读取,我们这里也用精确格式。
// 但存储时,为了避免时区混乱,建议统一为某个特定时区的纯时间戳字符串。
// 这里我建议统一存储为 UTC 时间的精确字符串。
// "2006-01-02 15:04:05.0000000" 这个布局对应不带时区后缀的纳秒精度。
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
}
// Scan 实现 sql.Scanner 接口,将数据库中的 TEXT 值扫描到 UTCTime。
// 所有时间字符串在读取时,都会被尝试解析为指定时区(或 UTC)的 time.Time 对象。
func (t *UTCTime) Scan(v interface{}) error {
// Scan implements the sql.Scanner interface.
// It scans a value from the database into a UTCTime object.
func (t *LocalTime) Scan(v interface{}) error {
if v == nil {
*t = UTCTime(time.Time{})
*t = LocalTime(time.Time{})
return nil
}
loc := GetAppLocation()
switch val := v.(type) {
case time.Time:
// If the driver already parsed it to time.Time, use it directly.
// Then convert to the application's desired location.
*t = UTCTime(val.In(GetAppLocation()))
// CRITICAL FIX: When the driver reads a timezone-less string (e.g., "16:00:00"),
// it often incorrectly assumes it's UTC. We must correct this by re-interpreting
// the date and clock values in the application's actual timezone.
year, month, day := val.Date()
hour, min, sec := val.Clock()
nanosec := val.Nanosecond()
*t = LocalTime(time.Date(year, month, day, hour, min, sec, nanosec, loc))
return nil
case []byte:
// If it's still a byte slice (string), parse it.
timeStr := strings.TrimSpace(string(val))
if timeStr == "" {
*t = UTCTime(time.Time{})
return nil
}
var parsedTime time.Time
var err error
layouts := []string{
"2006-01-02 15:04:05.0000000-07:00", // Your observed precise format with timezone
"2006-01-02 15:04:05-07:00", // With seconds and timezone
time.RFC3339Nano, // RFC3339 with nanoseconds
time.RFC3339, // RFC3339
"2006-01-02 15:04:05.0000000", // Nanoseconds, no timezone
"2006-01-02 15:04:05", // Standard, no timezone
"2006-01-02", // Date only
}
for _, layout := range layouts {
// time.Parse handles strings that include timezone info correctly.
parsedTime, err = time.Parse(layout, timeStr)
if err == nil {
break
}
}
if err != nil {
return fmt.Errorf("无法解析时间字符串 '%s' 为 UTCTime: %w", timeStr, err)
}
*t = UTCTime(parsedTime.In(GetAppLocation()))
return nil
return t.parseTime(string(val), loc)
case string:
return t.parseTime(val, loc)
default:
return fmt.Errorf("UTCTime scan source was not []byte or time.Time: %T (%v)", v, v)
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
}
}
// MarshalJSON implements json.Marshaler interface.
// This method defines how UTCTime should be serialized to JSON.
func (t UTCTime) MarshalJSON() ([]byte, error) {
// parseTime handles parsing a string into UTCTime.
func (t *LocalTime) parseTime(timeStr string, loc *time.Location) error {
timeStr = strings.TrimSpace(timeStr)
if timeStr == "" {
*t = LocalTime(time.Time{})
return nil
}
layouts := []string{
time.RFC3339Nano, time.RFC3339,
"2006-01-02 15:04:05.0000000-07:00", "2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05.0000000", "2006-01-02 15:04:05", "2006-01-02",
}
for _, layout := range layouts {
if parsedTime, err := time.ParseInLocation(layout, timeStr, loc); err == nil {
*t = LocalTime(parsedTime)
return nil
}
}
return fmt.Errorf("unable to parse time string '%s' into UTCTime", timeStr)
}
// MarshalJSON implements the json.Marshaler interface.
// Serializes UTCTime to JSON in RFC3339 format with the correct timezone offset.
func (t LocalTime) MarshalJSON() ([]byte, error) {
if time.Time(t).IsZero() {
// 如果是零值时间,返回 JSON null。
// 这样 API 返回的就是 "field": null,而不是 "field": {}。
return []byte("null"), nil
}
// 将时间转换为应用程序指定的时区,并格式化为 JSON 字符串。
// 使用双引号包裹,符合 JSON 字符串规范。
// 同样使用之前定义的精确格式,便于客户端解析。
formattedTime := time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000-07:00")
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
}
// ToTime 将 UTCTime 转换为 Go 的 time.Time 类型。
func (t UTCTime) ToTime() time.Time {
return time.Time(t)
}
// ToTime converts UTCTime to Go's native time.Time type.
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
// FromTime 将 Go 的 time.Time 类型转换为 UTCTime。
func FromTime(t time.Time) UTCTime {
return UTCTime(t)
}
// FromTime converts Go's native time.Time type to UTCTime.
func FromTime(t time.Time) LocalTime { return LocalTime(t) }
// Now returns the current time in the application's configured location.
func Now() UTCTime {
return UTCTime(time.Now().In(GetAppLocation()))
}
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
// AppLocation 用于存储应用程序全局使用的时区。
// 首次获取后会缓存,避免重复加载。
var (
appLocation *time.Location
locationOnce sync.Once
defaultLocation = time.UTC // 默认时区设为 UTC
appLocation *time.Location
locationOnce sync.Once
)
// GetAppLocation 获取应用程序的全局时区。
// 优先从环境变量 "TZ" 获取,否则使用默认的 UTC。
// GetAppLocation retrieves the application's global timezone from the "TZ" environment variable.
func GetAppLocation() *time.Location {
locationOnce.Do(func() {
tz := os.Getenv("TZ")
if tz != "" {
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("Warning: Failed to load timezone from environment variable TZ='%s', using default UTC. Error: %v\n", tz, err)
appLocation = defaultLocation
} else {
appLocation = loc
log.Printf("Info: Application timezone set from environment variable TZ='%s' (%s).\n", tz, appLocation.String())
}
} else {
appLocation = defaultLocation
log.Printf("Info: Environment variable TZ not set, using default UTC timezone (%s).\n", appLocation.String())
if tz == "" {
tz = "UTC"
}
time.Local = appLocation // 设置全局时区
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("Warning: Failed to load timezone '%s', falling back to UTC. Error: %v", tz, err)
appLocation = time.UTC
} else {
appLocation = loc
}
time.Local = appLocation
log.Printf("Application timezone is set to '%s'.", appLocation.String())
})
return appLocation
}
+7 -7
View File
@@ -1,11 +1,11 @@
package models
type Clipboard struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
Text string `json:"text" gorm:"type:longtext"`
Name string `json:"name" gorm:"type:varchar(255)"`
Weight int `json:"weight" gorm:"type:int"`
Remark string `json:"remark" gorm:"type:text"`
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
Text string `json:"text" gorm:"type:longtext"`
Name string `json:"name" gorm:"type:varchar(255)"`
Weight int `json:"weight" gorm:"type:int"`
Remark string `json:"remark" gorm:"type:text"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
+4 -17
View File
@@ -6,30 +6,17 @@ type Config struct {
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// OAuth 配置
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// Telegram Bot 配置
// TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
// 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'"`
@@ -39,6 +26,6 @@ type Config struct {
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt UTCTime
UpdatedAt UTCTime
CreatedAt LocalTime
UpdatedAt LocalTime
}
+6 -6
View File
@@ -1,10 +1,10 @@
package models
type Log struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
UUID string `json:"uuid" gorm:"type:varchar(36);"`
Message string `json:"message" gorm:"type:text;not null"`
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
Time UTCTime `json:"time" gorm:"autoCreateTime;not null"`
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
UUID string `json:"uuid" gorm:"type:varchar(36);"`
Message string `json:"message" gorm:"type:text;not null"`
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
Time LocalTime `json:"time" gorm:"autoCreateTime;not null"`
}
+6
View File
@@ -0,0 +1,6 @@
package models
type MessageSenderProvider struct {
Name string `json:"name" gorm:"primaryKey;unique;not null"`
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
}
+60 -59
View File
@@ -8,34 +8,35 @@ import (
// Client represents a registered client device
type Client struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
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)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// User represents an authenticated user
@@ -47,45 +48,45 @@ type User struct {
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"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// Session manages user sessions
type Session struct {
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)"`
LatestOnline UTCTime `json:"latest_online" gorm:"type:timestamp"`
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
Expires UTCTime `json:"expires" gorm:"not null"`
CreatedAt UTCTime `json:"created_at"`
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)"`
LatestOnline LocalTime `json:"latest_online" gorm:"type:timestamp"`
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
Expires LocalTime `json:"expires" gorm:"not null"`
CreatedAt LocalTime `json:"created_at"`
}
// Record logs client metrics over time
type Record struct {
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)"`
Ram int64 `json:"ram" gorm:"type:bigint"`
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
Swap int64 `json:"swap" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
Disk int64 `json:"disk" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
NetIn int64 `json:"net_in" gorm:"type:bigint"`
NetOut int64 `json:"net_out" gorm:"type:bigint"`
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
Client string `json:"client" gorm:"type:varchar(36);index"`
Time LocalTime `json:"time" gorm:"index"`
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
Ram int64 `json:"ram" gorm:"type:bigint"`
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
Swap int64 `json:"swap" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
Disk int64 `json:"disk" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
NetIn int64 `json:"net_in" gorm:"type:bigint"`
NetOut int64 `json:"net_out" gorm:"type:bigint"`
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
}
// StringArray represents a slice of strings stored as JSON in the database
+3 -3
View File
@@ -6,8 +6,8 @@ type OfflineNotification struct {
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"` // 上次通知时间
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
}
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
@@ -19,5 +19,5 @@ type LoadNotification struct {
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"` // 上次通知时间
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
}
+6
View File
@@ -0,0 +1,6 @@
package models
type OidcProvider struct {
Name string `json:"name" gorm:"primaryKey;unique;not null"`
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
}
+6 -6
View File
@@ -1,12 +1,12 @@
package models
type PingRecord struct {
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 值,单位毫秒
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 LocalTime `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
}
type PingTask struct {
+7 -7
View File
@@ -8,11 +8,11 @@ type Task struct {
}
type TaskResult struct {
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"`
CreatedAt UTCTime `json:"created_at" gorm:"type:timestamp"`
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 *LocalTime `json:"finished_at" gorm:"type:timestamp"`
CreatedAt LocalTime `json:"created_at" gorm:"type:timestamp"`
}
+32
View File
@@ -0,0 +1,32 @@
package database
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetAllOidcConfigs() []models.OidcProvider {
db := dbcore.GetDBInstance()
var result []models.OidcProvider
if err := db.Find(&result).Error; err != nil {
return nil
}
return result
}
func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
db := dbcore.GetDBInstance()
var config models.OidcProvider
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
func SaveOidcConfig(config *models.OidcProvider) error {
db := dbcore.GetDBInstance()
if err := db.Save(config).Error; err != nil {
return err
}
return nil
}
+115
View File
@@ -0,0 +1,115 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komariは、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Webインターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
## 特徴
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
## クイックスタート
### 0. クラウドホスティングによるワンクリック展開
- Rainyun - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. ワンクリックインストールスクリプトを使用する
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker展開
1. データディレクトリを作成します:
```bash
mkdir -p ./data
```
2. Dockerコンテナを実行します:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. デフォルトのユーザー名とパスワードを表示します:
```bash
docker logs komari
```
4. ブラウザで `http://<your_server_ip>:25774` にアクセスします。
> [!NOTE]
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
### 3. バイナリファイル展開
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komariを実行します:
```bash
./komari server -l 0.0.0.0:25774
```
3. ブラウザで `http://<your_server_ip>:25774` にアクセスします。デフォルトのポートは `25774` です。
4. デフォルトのユーザー名とパスワードは、起動ログで確認するか、環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を介して設定できます。
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
### 手動ビルド
#### 依存関係
- Go 1.18+ および Node.js 20+(手動ビルド用)
1. フロントエンドの静的ファイルをビルドします:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. バックエンドをビルドします:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
```bash
go build -o komari
```
4. 実行:
```bash
./komari server -l 0.0.0.0:25774
```
デフォルトのリスニングポートは `25774` です。`http://localhost:25774` にアクセスします。
## フロントエンド開発ガイド
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## クライアントエージェント開発ガイド
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
## 貢献
IssueやPull Requestを歓迎します!
## 謝辞
- こんなに暇でいられる自分に感謝
- PRを提出し、テーマを作成してくれたすべての開発者
## Star履歴
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+118
View File
@@ -0,0 +1,118 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單、高效的伺服器性能監控解決方案。它支援透過 Web 介面查看伺服器狀態,並透過輕量級 Agent 收集數據。
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
## 特性
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
- **自託管**:完全掌控數據隱私,部署簡單。
- **Web 介面**:直觀的監控儀表盤,易於使用。
## 快速開始
### 0. 容器雲一鍵部署
- 雨雲雲應用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. 使用一鍵安裝腳本
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 建立資料目錄:
```bash
mkdir -p ./data
```
2. 執行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看預設帳號和密碼:
```bash
docker logs komari
```
4. 在瀏覽器中存取 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
### 3. 二進位檔案部署
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
2. 執行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在瀏覽器中存取 `http://<your_server_ip>:25774`,預設監聽 `25774` 連接埠。
4. 預設帳號和密碼可在啟動日誌中查看,或透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 設定。
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
### 手工建置
#### 依賴
- Go 1.18+ 和 Node.js 20+(手工建置)
1. 建置前端靜態檔案:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 建置後端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
```bash
go build -o komari
```
4. 執行:
```bash
./komari server -l 0.0.0.0:25774
```
預設監聽 `25774` 連接埠,存取 `http://localhost:25774`。
## 前端開發指南
[Komari 主題開發指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻譯 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客戶端 Agent 開發指南
[Komari Agent 資訊上報與事件處理文檔](https://komari-document.pages.dev/dev/agent.html)
## 貢獻
歡迎提交 Issue 或 Pull Request!
## 鳴謝
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感謝我自己能這麼閒
- 提交PR、製作主題的各位開發者
## 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)
+117
View File
@@ -0,0 +1,117 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
## 快速开始
### 0. 容器云一键部署
- 雨云云应用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
```
2. 运行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看默认账号和密码:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
### 手工构建
#### 依赖
- Go 1.18+ 和 Node.js 20+(手工构建)
1. 构建前端静态文件:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 构建后端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
```bash
go build -o komari
```
4. 运行:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
## 前端开发指南
[Komari 主题开发指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻译 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-document.pages.dev/dev/agent.html)
## 贡献
欢迎提交 Issue 或 Pull Request!
## 鸣谢
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感谢我自己能这么闲
- 提交PR、制作主题的各位开发者
## 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)
+37
View File
@@ -0,0 +1,37 @@
package item
import "reflect"
type Item struct {
Name string `json:"name"`
Required bool `json:"required"`
Type string `json:"type"`
Options string `json:"options"`
Default string `json:"default"`
Help string `json:"help"`
}
func Parse(v any) []Item {
val := reflect.ValueOf(v)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
var items []Item
for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i)
item := Item{
Name: field.Tag.Get("json"),
Required: field.Tag.Get("required") == "true",
Type: field.Type.Name(),
Options: field.Tag.Get("options"),
Default: field.Tag.Get("default"),
Help: field.Tag.Get("help"),
}
if item.Type == "" {
item.Type = "string"
}
items = append(items, item)
}
return items
}
+10
View File
@@ -0,0 +1,10 @@
package messageSender
import (
_ "github.com/komari-monitor/komari/utils/messageSender/email"
_ "github.com/komari-monitor/komari/utils/messageSender/empty"
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
)
func All() {
}
-91
View File
@@ -1,91 +0,0 @@
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,
)
}
}
+105
View File
@@ -0,0 +1,105 @@
package email
import (
"crypto/tls"
"fmt"
"net/smtp"
"strconv"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type EmailSender struct {
Addition
}
func (e *EmailSender) GetName() string {
return "email"
}
func (e *EmailSender) GetConfiguration() factory.Configuration {
return &e.Addition
}
func (e *EmailSender) Init() error {
return nil
}
func (e *EmailSender) Destroy() error {
return nil
}
func (e *EmailSender) SendTextMessage(message, title string) error {
if e.Addition.Host == "" || e.Addition.Sender == "" || e.Addition.Username == "" || e.Addition.Password == "" || e.Addition.Receiver == "" {
return fmt.Errorf("email sending is not fully configured")
}
auth := smtp.PlainAuth(
"",
e.Addition.Username,
e.Addition.Password,
e.Addition.Host,
)
msg := []byte("To: " + e.Addition.Receiver + "\r\n" +
"From: " + e.Addition.Sender + "\r\n" +
"Subject: " + title + "\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" +
message)
addr := e.Addition.Host + ":" + strconv.Itoa(e.Addition.Port)
if e.Addition.UseSSL {
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: e.Addition.Host}); 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(e.Addition.Sender); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err = c.Rcpt(e.Addition.Receiver); 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,
e.Addition.Sender,
[]string{e.Addition.Receiver},
msg,
)
}
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*EmailSender)(nil)
+21
View File
@@ -0,0 +1,21 @@
package email
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
Host string `json:"host" required:"true"`
Port int `json:"port" required:"true" default:"587"`
Username string `json:"username"`
Password string `json:"password"`
Sender string `json:"sender"`
Receiver string `json:"receiver"`
UseSSL bool `json:"use_ssl" default:"false"`
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &EmailSender{}
})
}
-9
View File
@@ -1,9 +0,0 @@
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")
}
+43
View File
@@ -0,0 +1,43 @@
package empty
import (
"fmt"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
}
type EmptyProvider struct {
Addition
}
func (e *EmptyProvider) GetName() string {
return "empty"
}
func (e *EmptyProvider) GetConfiguration() factory.Configuration {
return &e.Addition
}
func (e *EmptyProvider) Init() error {
return nil
}
func (e *EmptyProvider) Destroy() error {
return nil
}
func (e *EmptyProvider) SendTextMessage(message, title string) error {
return fmt.Errorf("you are using an empty message sender, please check your configuration")
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &EmptyProvider{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*EmptyProvider)(nil)
+60
View File
@@ -0,0 +1,60 @@
package factory
import (
"log"
"github.com/komari-monitor/komari/utils/item"
)
var (
senders = make(map[string]IMessageSender)
senderConstructor = make(map[string]MessageSenderConstructor)
sendersAdditionalItems = make(map[string][]item.Item)
)
func RegisterMessageSender(constructor MessageSenderConstructor) {
sender := constructor()
senderConstructor[sender.GetName()] = constructor
if sender == nil {
panic("Message sender constructor returned nil")
}
if _, exists := senders[sender.GetName()]; exists {
log.Println("Message sender already registered: " + sender.GetName())
}
senders[sender.GetName()] = sender
// 使用反射来提取提供程序的配置字段
config := sender.GetConfiguration()
items := item.Parse(config)
sendersAdditionalItems[sender.GetName()] = items
}
func GetSenderConfigs() map[string][]item.Item {
return sendersAdditionalItems
}
func GetAllMessageSenders() map[string]IMessageSender {
return senders
}
func GetConstructor(name string) (MessageSenderConstructor, bool) {
constructor, exists := senderConstructor[name]
return constructor, exists
}
func GetAllMessageSenderNames() []string {
names := make([]string, 0, len(senders))
for name := range senders {
names = append(names, name)
}
return names
}
func Initialize() {
for _, sender := range senders {
if err := sender.Init(); err != nil {
log.Printf("Failed to initialize message sender %s: %v", sender.GetName(), err)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
package factory
type IMessageSender interface {
GetName() string
// 请务必返回 &Configuration{} 的指针
GetConfiguration() Configuration
SendTextMessage(message, title string) error
Init() error
Destroy() error
}
type Configuration interface{}
type MessageSenderConstructor func() IMessageSender
+52
View File
@@ -0,0 +1,52 @@
package messageSender
import (
"encoding/json"
"fmt"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func LoadProvider(name string, addition string) error {
mu.Lock()
defer mu.Unlock()
constructor, exists := factory.GetConstructor(name)
if !exists {
return fmt.Errorf("message sender provider not found: %s", name)
}
provider := constructor()
err := json.Unmarshal([]byte(addition), provider.GetConfiguration())
if err != nil {
return fmt.Errorf("failed to load config for provider %s: %w", name, err)
}
provider.Init()
if currentProvider != nil {
currentProvider.Destroy()
}
currentProvider = provider
return nil
}
func GetProviderConfiguration(name string) (map[string]interface{}, error) {
constructor, exists := factory.GetConstructor(name)
if !exists {
return nil, fmt.Errorf("message sender provider not found: %s", name)
}
provider := constructor()
config := provider.GetConfiguration()
// 将配置转换为map
configBytes, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("failed to marshal configuration: %w", err)
}
var configMap map[string]interface{}
if err := json.Unmarshal(configBytes, &configMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal configuration: %w", err)
}
return configMap, nil
}
+26
View File
@@ -0,0 +1,26 @@
package messageSender
import (
"testing"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func Test(t *testing.T) {
senders := factory.GetAllMessageSenders()
if len(senders) == 0 {
t.Error("No message senders found")
return
}
cfg := factory.GetSenderConfigs()
if len(cfg) == 0 {
t.Error("No sender configs found")
return
}
LoadProvider("email", `{"host":"smtp.example.com","port":587,"username":"user","password":"pass"}`)
cp := CurrentProvider
if cp() == nil {
t.Error("Current provider is nil")
return
}
}
+56 -20
View File
@@ -1,40 +1,76 @@
package messageSender
import (
"encoding/json"
"fmt"
"log"
"sync"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
var CurrentProvider MessageSender
var (
currentProvider factory.IMessageSender
mu = sync.Mutex{}
once = sync.Once{}
)
func init() {
CurrentProvider = &EmptyProvider{}
func CurrentProvider() factory.IMessageSender {
mu.Lock()
defer mu.Unlock()
return currentProvider
}
func Initialize() {
cfg, err := config.Get()
if err != nil {
CurrentProvider = &EmptyProvider{}
go func() {
once.Do(func() {
all := factory.GetAllMessageSenders()
for _, provider := range all {
if _, err := database.GetMessageSenderConfigByName(provider.GetName()); err == nil {
continue
}
// 如果数据库中没有该提供者的配置,则保存默认配置
config := provider.GetConfiguration()
configBytes, err := json.Marshal(config)
if err != nil {
log.Printf("Failed to marshal config for provider %s: %v", provider.GetName(), err)
return
}
if err := database.SaveMessageSenderConfig(&models.MessageSenderProvider{
Name: provider.GetName(),
Addition: string(configBytes),
}); err != nil {
log.Printf("Failed to save default config for provider %s: %v", provider.GetName(), err)
return
}
}
})
}()
cfg, _ := config.Get()
if cfg.NotificationMethod == "" || cfg.NotificationMethod == "none" {
LoadProvider("empty", "{}")
return
}
switch cfg.NotificationMethod {
case "telegram":
CurrentProvider = &TelegramMessageSender{}
case "email":
CurrentProvider = &EmailMessageSender{}
case "none", "":
CurrentProvider = &EmptyProvider{}
default:
CurrentProvider = &EmptyProvider{}
// 尝试从数据库加载配置
senderConfig, err := database.GetMessageSenderConfigByName(cfg.NotificationMethod)
if err != nil {
// 如果没有找到配置,使用empty provider
LoadProvider("empty", "{}")
return
}
}
type MessageSender interface {
SendTextMessage(message, title string) error
LoadProvider(cfg.NotificationMethod, senderConfig.Addition)
}
func SendTextMessage(message string, title string) error {
if CurrentProvider() == nil {
return fmt.Errorf("message sender provider is not initialized")
}
var err error
cfg, err := config.Get()
if err != nil {
@@ -44,7 +80,7 @@ func SendTextMessage(message string, title string) error {
return nil
}
for i := 0; i < 3; i++ {
err = CurrentProvider.SendTextMessage(message, title)
err = CurrentProvider().SendTextMessage(message, title)
if err == nil {
auditlog.Log("", "", "Message sent: "+title, "info")
return nil
-64
View File
@@ -1,64 +0,0 @@
package messageSender
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/komari-monitor/komari/database/config"
)
// 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 err error
conf, err := config.Get()
if err != nil {
return err
}
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
}
+8
View File
@@ -0,0 +1,8 @@
package telegram
type Addition struct {
BotToken string `json:"bot_token" required:"true"`
ChatID string `json:"chat_id" required:"true"`
MessageThreadID string `json:"message_thread_id" help:"Optional. Unique identifier of a message thread to which the message belongs; for supergroups only"`
Endpoint string `json:"endpoint" required:"true" default:"https://api.telegram.org/bot" help:"Telegram API endpoint, default is https://api.telegram.org/bot"`
}
+89
View File
@@ -0,0 +1,89 @@
package telegram
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type TelegramSender struct {
Addition
}
func (t *TelegramSender) GetName() string {
return "telegram"
}
func (t *TelegramSender) GetConfiguration() factory.Configuration {
return &t.Addition
}
func (t *TelegramSender) Init() error {
// 初始化逻辑,如果需要的话
return nil
}
func (t *TelegramSender) Destroy() error {
// 清理逻辑,如果需要的话
return nil
}
func (t *TelegramSender) SendTextMessage(message, title string) error {
fullMessage := message
if title != "" {
fullMessage = fmt.Sprintf("<b>%s</b>\n%s", title, message)
}
if fullMessage == "" {
return errors.New("message is empty")
}
endpoint := t.Addition.Endpoint + t.Addition.BotToken + "/sendMessage"
data := url.Values{}
data.Set("chat_id", t.Addition.ChatID)
data.Set("text", fullMessage)
data.Set("parse_mode", "HTML")
// Add message_thread_id if provided
if t.Addition.MessageThreadID != "" {
data.Set("message_thread_id", t.Addition.MessageThreadID)
}
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
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &TelegramSender{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*TelegramSender)(nil)
+11
View File
@@ -0,0 +1,11 @@
package oauth
import (
_ "github.com/komari-monitor/komari/utils/oauth/factory"
_ "github.com/komari-monitor/komari/utils/oauth/generic"
_ "github.com/komari-monitor/komari/utils/oauth/github"
)
func All() {
//empty function to ensure all OIDC providers are registered
}
+59
View File
@@ -0,0 +1,59 @@
package factory
import (
"log"
"github.com/komari-monitor/komari/utils/item"
)
var (
providers = make(map[string]IOidcProvider)
providerConstructor = make(map[string]OidcConstructor)
providersAdditionalItems = make(map[string][]item.Item)
)
func RegisterOidcProvider(constructor OidcConstructor) {
provider := constructor()
providerConstructor[provider.GetName()] = constructor
if provider == nil {
panic("OIDC provider constructor returned nil")
}
if _, exists := providers[provider.GetName()]; exists {
log.Println("OIDC provider already registered: " + provider.GetName())
}
providers[provider.GetName()] = provider
// 使用反射来提取提供程序的配置字段
config := provider.GetConfiguration()
items := item.Parse(config)
providersAdditionalItems[provider.GetName()] = items
}
func GetProviderConfigs() map[string][]item.Item {
return providersAdditionalItems
}
func GetAllOidcProviders() map[string]IOidcProvider {
return providers
}
func GetConstructor(name string) (OidcConstructor, bool) {
constructor, exists := providerConstructor[name]
return constructor, exists
}
func GetAllOidcProviderNames() []string {
names := make([]string, 0, len(providers))
for name := range providers {
names = append(names, name)
}
return names
}
func Initialize() {
for _, provider := range providers {
if err := provider.Init(); err != nil {
log.Printf("Failed to initialize OIDC provider %s: %v", provider.GetName(), err)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package factory
import "context"
type IOidcProvider interface {
GetName() string
// 请务必返回 &Configuration{} 的指针
GetConfiguration() Configuration
// 获取授权URL和状态
GetAuthorizationURL() (string, string)
OnCallback(ctx context.Context, state string, query map[string]string) (OidcCallback, error)
Init() error
Destroy() error
}
type OidcCallback struct {
UserId string
}
type Configuration interface{}
type OidcConstructor func() IOidcProvider
+33
View File
@@ -0,0 +1,33 @@
package oauth
import (
"testing"
"github.com/komari-monitor/komari/utils/oauth/factory"
)
// Test function
func TestRegisterAndGetProviderConfigs(t *testing.T) {
All()
configs := factory.GetProviderConfigs()
if len(configs) == 0 {
t.Error("Expected non-empty provider configs, got empty")
}
providers := factory.GetAllOidcProviders()
if len(providers) == 0 {
t.Error("Expected non-empty OIDC providers, got empty")
}
names := factory.GetAllOidcProviderNames()
if len(names) == 0 {
t.Error("Expected non-empty OIDC provider names, got empty")
}
err := LoadProvider("github", `{"client_id":"test_id","client_secret":"test_secret"}`)
if err != nil {
t.Errorf("Failed to load provider: %v", err)
}
cfg := CurrentProvider().GetConfiguration()
if cfg == nil {
t.Error("Expected non-nil configuration for 'github' provider, got nil")
}
}
+114
View File
@@ -0,0 +1,114 @@
package generic
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func (g *Generic) GetName() string {
return "generic"
}
func (g *Generic) GetConfiguration() factory.Configuration {
return &g.Addition
}
func (g *Generic) GetAuthorizationURL() (string, string) {
state := utils.GenerateRandomString(16)
// 构建GitHub OAuth授权URL
authURL := fmt.Sprintf(
"%s?client_id=%s&state=%s&scope=%s&response_type=code",
g.Addition.AuthURL,
url.QueryEscape(g.Addition.ClientId),
url.QueryEscape(state),
url.QueryEscape(g.Addition.Scope),
)
g.stateCache.Set(state, true, cache.DefaultExpiration)
return authURL, state
}
func (g *Generic) OnCallback(ctx context.Context, state string, query map[string]string) (factory.OidcCallback, error) {
code := query["code"]
// 验证state防止CSRF攻击
if g.stateCache == nil {
return factory.OidcCallback{}, fmt.Errorf("state cache not initialized")
}
if _, ok := g.stateCache.Get(state); !ok {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
if state == "" {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
// 获取code
if code == "" {
return factory.OidcCallback{}, fmt.Errorf("no code provided")
}
// 获取访问令牌
data := url.Values{
"client_id": {g.Addition.ClientId},
"client_secret": {g.Addition.ClientSecret},
"code": {code},
"grant_type": {"authorization_code"},
}
req, _ := http.NewRequest("POST", g.Addition.TokenURL, nil)
req.URL.RawQuery = data.Encode()
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
}
// 获取用户信息
userReq, _ := http.NewRequest("GET", g.Addition.UserInfoURL, nil)
userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
userReq.Header.Set("Accept", "application/json")
userResp, err := http.DefaultClient.Do(userReq)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get user info: %v", err)
}
defer userResp.Body.Close()
var user map[string]interface{}
if err := json.NewDecoder(userResp.Body).Decode(&user); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse user info response: %v", err)
}
userId, ok := user[g.Addition.UserIDField]
if !ok {
return factory.OidcCallback{}, fmt.Errorf("user id field '%s' not found in user info response", g.Addition.UserIDField)
}
return factory.OidcCallback{UserId: fmt.Sprintf("%v", userId)}, nil
}
func (g *Generic) Init() error {
g.stateCache = cache.New(time.Minute*5, time.Minute*10)
return nil
}
func (g *Generic) Destroy() error {
g.stateCache.Flush()
return nil
}
var _ factory.IOidcProvider = (*Generic)(nil)
+27
View File
@@ -0,0 +1,27 @@
package generic
import (
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func init() {
factory.RegisterOidcProvider(func() factory.IOidcProvider {
return &Generic{}
})
}
type Generic struct {
Addition
stateCache *cache.Cache // 用于存储state和用户信息的映射
}
type Addition struct {
ClientId string `json:"client_id" required:"true"`
ClientSecret string `json:"client_secret" required:"true"`
AuthURL string `json:"auth_url" required:"true"`
TokenURL string `json:"token_url" required:"true"`
UserInfoURL string `json:"user_info_url" required:"true"`
Scope string `json:"scope"`
UserIDField string `json:"user_id_field" required:"true"`
}
-95
View File
@@ -1,95 +0,0 @@
package oauth
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils"
)
type GitHubUser struct {
ID int `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
}
// 获取OAuth的URL、state !!请务必验证state防止CSRF攻击
func GetOAuthUrl() (string, string) {
cfg, _ := config.Get()
state := utils.GenerateRandomString(16)
// 构建GitHub OAuth授权URL
authURL := fmt.Sprintf(
"https://github.com/login/oauth/authorize?client_id=%s&state=%s&scope=user:email",
url.QueryEscape(cfg.OAuthClientID),
url.QueryEscape(state),
)
return authURL, state
}
func OAuthCallback(code, state string) (GitHubUser, error) {
cfg, _ := config.Get()
// 验证state防止CSRF攻击
// state, _ := c.Cookie("oauth_state")
if state == "" {
return GitHubUser{}, fmt.Errorf("invalid state")
}
// 获取code
//code := c.Query("code")
if code == "" {
return GitHubUser{}, fmt.Errorf("no code provided")
}
// 获取访问令牌
tokenURL := "https://github.com/login/oauth/access_token"
data := url.Values{
"client_id": {cfg.OAuthClientID},
"client_secret": {cfg.OAuthClientSecret},
"code": {code},
}
req, _ := http.NewRequest("POST", tokenURL, nil)
req.URL.RawQuery = data.Encode()
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return GitHubUser{}, fmt.Errorf("failed to get access token: %v", err)
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return GitHubUser{}, fmt.Errorf("failed to parse access token response: %v", err)
}
// 获取用户信息
userReq, _ := http.NewRequest("GET", "https://api.github.com/user", nil)
userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
userReq.Header.Set("Accept", "application/json")
userResp, err := http.DefaultClient.Do(userReq)
if err != nil {
return GitHubUser{}, fmt.Errorf("failed to get user info: %v", err)
}
defer userResp.Body.Close()
var githubUser GitHubUser
if err := json.NewDecoder(userResp.Body).Decode(&githubUser); err != nil {
return GitHubUser{}, fmt.Errorf("failed to parse user info response: %v", err)
}
return githubUser, nil
}
+115
View File
@@ -0,0 +1,115 @@
package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func init() {
}
func (g *Github) GetName() string {
return "github"
}
func (g *Github) GetConfiguration() factory.Configuration {
return &g.Addition
}
func (g *Github) GetAuthorizationURL() (string, string) {
state := utils.GenerateRandomString(16)
// 构建GitHub OAuth授权URL
authURL := fmt.Sprintf(
"https://github.com/login/oauth/authorize?client_id=%s&state=%s&scope=user:email",
url.QueryEscape(g.Addition.ClientId),
url.QueryEscape(state),
)
g.stateCache.Set(state, true, cache.NoExpiration)
return authURL, state
}
func (g *Github) OnCallback(ctx context.Context, state string, query map[string]string) (factory.OidcCallback, error) {
code := query["code"]
// 验证state防止CSRF攻击
// state, _ := c.Cookie("oauth_state")
if g.stateCache == nil {
return factory.OidcCallback{}, fmt.Errorf("state cache not initialized")
}
if _, ok := g.stateCache.Get(state); !ok {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
if state == "" {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
// 获取code
//code := c.Query("code")
if code == "" {
return factory.OidcCallback{}, fmt.Errorf("no code provided")
}
// 获取访问令牌
tokenURL := "https://github.com/login/oauth/access_token"
data := url.Values{
"client_id": {g.Addition.ClientId},
"client_secret": {g.Addition.ClientSecret},
"code": {code},
}
req, _ := http.NewRequest("POST", tokenURL, nil)
req.URL.RawQuery = data.Encode()
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
}
// 获取用户信息
userReq, _ := http.NewRequest("GET", "https://api.github.com/user", nil)
userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
userReq.Header.Set("Accept", "application/json")
userResp, err := http.DefaultClient.Do(userReq)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get user info: %v", err)
}
defer userResp.Body.Close()
var githubUser GitHubUser
if err := json.NewDecoder(userResp.Body).Decode(&githubUser); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse user info response: %v", err)
}
return factory.OidcCallback{UserId: fmt.Sprintf("%d", githubUser.ID)}, nil
}
func (g *Github) Init() error {
g.stateCache = cache.New(time.Minute*5, time.Minute*10)
return nil
}
func (g *Github) Destroy() error {
g.stateCache.Flush()
return nil
}
var _ factory.IOidcProvider = (*Github)(nil)
+28
View File
@@ -0,0 +1,28 @@
package github
import (
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func init() {
factory.RegisterOidcProvider(func() factory.IOidcProvider {
return &Github{}
})
}
type Github struct {
Addition
stateCache *cache.Cache // 用于存储state和用户信息的映射
}
type Addition struct {
ClientId string `json:"client_id" required:"true"`
ClientSecret string `json:"client_secret" required:"true"`
}
type GitHubUser struct {
ID int `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
}
+74
View File
@@ -0,0 +1,74 @@
package oauth
import (
"encoding/json"
"fmt"
"log"
"sync"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/oauth/factory"
)
var (
currentProvider factory.IOidcProvider
mu = sync.Mutex{}
once = sync.Once{}
)
func CurrentProvider() factory.IOidcProvider {
mu.Lock()
defer mu.Unlock()
return currentProvider
}
func LoadProvider(name string, configJson string) error {
mu.Lock()
defer mu.Unlock()
go func() {
once.Do(func() {
all := factory.GetAllOidcProviders()
for _, provider := range all {
if _, err := database.GetOidcConfigByName(provider.GetName()); err == nil {
continue
}
// 如果数据库中没有该提供者的配置,则保存默认配置
config := provider.GetConfiguration()
configBytes, err := json.Marshal(config)
if err != nil {
log.Printf("Failed to marshal config for provider %s: %v", provider.GetName(), err)
return
}
if err := database.SaveOidcConfig(&models.OidcProvider{
Name: provider.GetName(),
Addition: string(configBytes),
}); err != nil {
log.Printf("Failed to save default config for provider %s: %v", provider.GetName(), err)
return
}
}
})
}()
if currentProvider != nil && currentProvider.GetName() == name {
return nil // 已经加载了相同的提供程序
}
if currentProvider != nil {
if err := currentProvider.Destroy(); err != nil {
log.Printf("Failed to destroy provider %s: %v", currentProvider.GetName(), err)
}
}
constructor, exists := factory.GetConstructor(name)
if !exists {
return fmt.Errorf("provider %s not found", name)
}
currentProvider = constructor()
if err := json.Unmarshal([]byte(configJson), currentProvider.GetConfiguration()); err != nil {
return fmt.Errorf("failed to unmarshal config for provider %s: %w", name, err)
}
err := currentProvider.Init()
if err != nil {
return fmt.Errorf("failed to initialize provider %s: %w", name, err)
}
return nil
}
+3
View File
@@ -76,6 +76,9 @@ func CheckAndAutoRenewal(client models.Client) {
} else if billingCycle >= 1080 && billingCycle <= 1150 {
// 三年计费 - 加3年
newExpireTime = baseTime.AddDate(3, 0, 0)
} else if billingCycle >= 1800 && billingCycle <= 1850 {
// 五年计费 - 加5年
newExpireTime = baseTime.AddDate(5, 0, 0)
} else {
// 其他情况,直接加上账单周期天数
newExpireTime = baseTime.AddDate(0, 0, billingCycle)