mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-13 18:00:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efb4cc6a4d | ||
|
|
09dd2ad329 | ||
|
|
5f079c46d6 | ||
|
|
ec899e3ee4 | ||
|
|
f7813e4b1b | ||
|
|
f5d00c0a2a | ||
|
|
9b9348b341 | ||
|
|
b5266cda66 | ||
|
|
d6d716c5ab | ||
|
|
de5a611718 | ||
|
|
d595acbde3 | ||
|
|
638ce9ae6c | ||
|
|
bd2c67d725 | ||
|
|
f8ae5418c9 | ||
|
|
5e1959dec3 | ||
|
|
b7a276dd05 | ||
|
|
b2b3db616a | ||
|
|
17e1a8828c | ||
|
|
1674220340 | ||
|
|
ab76d51b84 | ||
|
|
4863e623ae | ||
|
|
ac88159d56 | ||
|
|
433bc52788 | ||
|
|
6aeb444b2b | ||
|
|
bbf631714d | ||
|
|
c204094173 | ||
|
|
417cec5d44 | ||
|
|
5401b4c10e |
No files matched your search
@@ -20,6 +20,25 @@ jobs:
|
||||
matrix:
|
||||
goos: [windows, linux]
|
||||
goarch: [amd64, arm64]
|
||||
include:
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
gcc: x86_64-w64-mingw32-gcc
|
||||
CGO_CFLAGS: ''
|
||||
CGO_LDFLAGS: ''
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
gcc: gcc
|
||||
CGO_CFLAGS: ''
|
||||
CGO_LDFLAGS: ''
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
gcc: aarch64-linux-gnu-gcc
|
||||
CGO_CFLAGS: '-pthread'
|
||||
CGO_LDFLAGS: '-pthread'
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -28,7 +47,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '23'
|
||||
node-version: "23"
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
@@ -43,13 +62,33 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install cross-compiler
|
||||
run: |
|
||||
sudo apt-get update
|
||||
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
sudo apt-get install gcc-aarch64-linux-gnu
|
||||
fi
|
||||
if [ "${{ matrix.goarch }}" = "amd64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
sudo apt-get install mingw-w64
|
||||
fi
|
||||
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
sudo apt-get install wget xz-utils
|
||||
mkdir -p /tmp/mingw64
|
||||
wget https://github.com/mstorsjo/llvm-mingw/releases/download/20250613/llvm-mingw-20250613-ucrt-ubuntu-22.04-aarch64.tar.xz -O /tmp/mingw64.tar.xz
|
||||
tar -xf /tmp/mingw64.tar.xz -C /tmp/mingw64 --strip-components=1
|
||||
fi
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 0
|
||||
CGO_ENABLED: 1
|
||||
CC: ${{ matrix.gcc }}
|
||||
CGO_CFLAGS: ${{ matrix.CGO_CFLAGS }}
|
||||
CGO_LDFLAGS: ${{ matrix.CGO_LDFLAGS }}
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
@@ -102,4 +141,4 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ github.event.release.tag_name }}
|
||||
VERSION_HASH=${{ github.sha }}
|
||||
VERSION_HASH=${{ github.sha }}
|
||||
@@ -84,7 +84,7 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
|
||||
## 鸣谢
|
||||
- 感谢 [Alice Networks LTD](https://app.alice.ws/) 提供的免费服务器
|
||||
- 感谢我自己能这么闲
|
||||
|
||||
## 引用
|
||||
- [gorm.io](https://gorm.io/)
|
||||
@@ -101,6 +101,15 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
## 许可证
|
||||
[MIT License](LICENSE)
|
||||
|
||||
## 截图
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
## 碎碎念
|
||||
|
||||
### 我为什么做Komari?
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"image/png"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func Generate2FA(c *gin.Context) {
|
||||
secret, img, err := accounts.Generate2Fa()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to generate 2FA: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("2fa_secret", secret, 1800, "/", "", false, true)
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Writer.WriteHeader(200)
|
||||
png.Encode(c.Writer, img)
|
||||
}
|
||||
|
||||
func Enable2FA(c *gin.Context) {
|
||||
uuid, _ := c.Get("uuid")
|
||||
secret, _ := c.Cookie("2fa_secret")
|
||||
code := c.Query("code")
|
||||
if secret == "" || uuid == nil || code == "" {
|
||||
api.RespondError(c, 400, "2FA secret or code not provided")
|
||||
return
|
||||
}
|
||||
if !totp.Validate(code, secret) {
|
||||
api.RespondError(c, 400, "Invalid 2FA code")
|
||||
return
|
||||
}
|
||||
err := accounts.Enable2Fa(uuid.(string), secret)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to enable 2FA: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("2fa_secret", "", -1, "/", "", false, true)
|
||||
|
||||
api.RespondSuccess(c, "2FA enabled successfully")
|
||||
}
|
||||
|
||||
func Disable2FA(c *gin.Context) {
|
||||
uuid, _ := c.Get("uuid")
|
||||
err := accounts.Disable2Fa(uuid.(string))
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to disable 2FA: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, "")
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// GetClipboard retrieves a clipboard entry by ID
|
||||
func GetClipboard(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
cb, err := clipboardDB.GetClipboardByID(id)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, cb)
|
||||
}
|
||||
|
||||
// ListClipboard lists all clipboard entries
|
||||
func ListClipboard(c *gin.Context) {
|
||||
list, err := clipboardDB.ListClipboard()
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, list)
|
||||
}
|
||||
|
||||
// CreateClipboard creates a new clipboard entry
|
||||
func CreateClipboard(c *gin.Context) {
|
||||
var req models.Clipboard
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.CreateClipboard(&req); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to create clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
userUUID, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
|
||||
api.RespondSuccess(c, req)
|
||||
}
|
||||
|
||||
// UpdateClipboard updates an existing clipboard entry
|
||||
func UpdateClipboard(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
var fields map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&fields); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to update clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
userUUID, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// DeleteClipboard deletes a clipboard entry
|
||||
func DeleteClipboard(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.DeleteClipboard(id); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to delete clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
userUUID, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// BatchDeleteClipboard deletes multiple clipboard entries
|
||||
func BatchDeleteClipboard(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []int `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.DeleteClipboardBatch(req.IDs); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to batch delete clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
userUUID, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// POST body : []uuid
|
||||
func EnableOfflineNotification(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var notifications []models.OfflineNotification
|
||||
for _, uuid := range uuids {
|
||||
notifications = append(notifications, models.OfflineNotification{
|
||||
Client: uuid,
|
||||
Enable: true,
|
||||
})
|
||||
}
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// POST body : []uuid
|
||||
func DisableOfflineNotification(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var notifications []models.OfflineNotification
|
||||
for _, uuid := range uuids {
|
||||
notifications = append(notifications, models.OfflineNotification{
|
||||
Client: uuid,
|
||||
Enable: false,
|
||||
})
|
||||
}
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func EditOfflineNotification(c *gin.Context) {
|
||||
var notifications []models.OfflineNotification
|
||||
if err := c.ShouldBindJSON(¬ifications); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(notifications) == 0 {
|
||||
api.RespondError(c, 400, "At least one notification is required")
|
||||
return
|
||||
}
|
||||
for _, noti := range notifications {
|
||||
if noti.Client == "" {
|
||||
api.RespondError(c, 400, "Client UUID cannot be empty")
|
||||
return
|
||||
}
|
||||
if noti.GracePeriod <= 0 || noti.Cooldown <= 0 {
|
||||
api.RespondError(c, 400, "GracePeriod and Cooldown must be positive integers")
|
||||
return
|
||||
}
|
||||
}
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable", "cooldown", "grace_period"}),
|
||||
}).
|
||||
Select("*").
|
||||
Create(notifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func ListOfflineNotifications(c *gin.Context) {
|
||||
var notifications []models.OfflineNotification
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(¬ifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, notifications)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/utils/notification"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
@@ -109,10 +110,11 @@ func WebSocketReport(c *gin.Context) {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
|
||||
return
|
||||
}
|
||||
|
||||
ws.ConnectedClients[uuid] = conn
|
||||
go notification.OnlineNotification(uuid)
|
||||
defer func() {
|
||||
delete(ws.ConnectedClients, uuid)
|
||||
notification.OfflineNotification(uuid)
|
||||
}()
|
||||
|
||||
for {
|
||||
|
||||
+23
-14
@@ -15,6 +15,7 @@ import (
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TwoFa string `json:"2fa_code"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
@@ -40,22 +41,30 @@ func Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if uuid, success := accounts.CheckPassword(data.Username, data.Password); success {
|
||||
|
||||
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
|
||||
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
|
||||
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
|
||||
return
|
||||
} else {
|
||||
uuid, success := accounts.CheckPassword(data.Username, data.Password)
|
||||
if !success {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// 2FA
|
||||
user, _ := accounts.GetUserByUUID(uuid)
|
||||
if user.TwoFactor != "" && data.TwoFa == "" {
|
||||
RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
|
||||
return
|
||||
}
|
||||
// Create session
|
||||
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
|
||||
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
|
||||
}
|
||||
func Logout(c *gin.Context) {
|
||||
session, _ := c.Cookie("session_token")
|
||||
|
||||
@@ -22,6 +22,6 @@ func GetMe(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID})
|
||||
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID, "2fa_enabled": user.TwoFactor != ""})
|
||||
|
||||
}
|
||||
+9
-6
@@ -5,11 +5,13 @@ import (
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
Username string
|
||||
//Username string
|
||||
NewPassword string
|
||||
)
|
||||
|
||||
@@ -17,7 +19,7 @@ var ChpasswdCmd = &cobra.Command{
|
||||
Use: "chpasswd",
|
||||
Short: "Force change password",
|
||||
Long: `Force change password`,
|
||||
Example: `komari chpasswd -u <username> -p <password>`,
|
||||
Example: `komari chpasswd -p <password>`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if NewPassword == "" {
|
||||
cmd.Help()
|
||||
@@ -27,9 +29,10 @@ var ChpasswdCmd = &cobra.Command{
|
||||
cmd.Println("Database file does not exist.")
|
||||
return
|
||||
}
|
||||
|
||||
cmd.Println("Changing password for user:", Username)
|
||||
if err := accounts.ForceResetPassword(Username, NewPassword); err != nil {
|
||||
user := &models.User{}
|
||||
dbcore.GetDBInstance().Model(&models.User{}).First(user)
|
||||
cmd.Println("Changing password for user:", user.Username)
|
||||
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
return
|
||||
}
|
||||
@@ -45,7 +48,7 @@ var ChpasswdCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
ChpasswdCmd.PersistentFlags().StringVarP(&Username, "user", "u", "admin", "The username of the account to change password")
|
||||
//ChpasswdCmd.PersistentFlags().StringVarP(&Username, "user", "u", "admin", "The username of the account to change password")
|
||||
ChpasswdCmd.PersistentFlags().StringVarP(&NewPassword, "password", "p", "", "New password")
|
||||
RootCmd.AddCommand(ChpasswdCmd)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var Disable2FA = &cobra.Command{
|
||||
Use: "disable-2fa",
|
||||
Short: "Force disable 2FA",
|
||||
Long: `Force disable 2FA`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&models.User{}).Where("two_factor != ?", "").
|
||||
Update("two_factor", "").Error
|
||||
})
|
||||
if err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cmd.Println("2FA has been disabled.")
|
||||
cmd.Println("Please restart the server to apply the changes.")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(Disable2FA)
|
||||
}
|
||||
@@ -12,7 +12,9 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/api/admin"
|
||||
"github.com/komari-monitor/komari/api/admin/clipboard"
|
||||
log_api "github.com/komari-monitor/komari/api/admin/log"
|
||||
"github.com/komari-monitor/komari/api/admin/notification"
|
||||
"github.com/komari-monitor/komari/api/admin/test"
|
||||
"github.com/komari-monitor/komari/api/admin/update"
|
||||
"github.com/komari-monitor/komari/api/client"
|
||||
@@ -161,7 +163,33 @@ var ServerCmd = &cobra.Command{
|
||||
sessionGroup.POST("/remove", admin.DeleteSession)
|
||||
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
|
||||
}
|
||||
two_factorGroup := adminAuthrized.Group("/2fa")
|
||||
{
|
||||
two_factorGroup.GET("/generate", admin.Generate2FA)
|
||||
two_factorGroup.POST("/enable", admin.Enable2FA)
|
||||
two_factorGroup.POST("/disable", admin.Disable2FA)
|
||||
}
|
||||
adminAuthrized.GET("/logs", log_api.GetLogs)
|
||||
|
||||
// clipboard
|
||||
clipboardGroup := adminAuthrized.Group("/clipboard")
|
||||
{
|
||||
clipboardGroup.GET("/:id", clipboard.GetClipboard)
|
||||
clipboardGroup.GET("", clipboard.ListClipboard)
|
||||
clipboardGroup.POST("", clipboard.CreateClipboard)
|
||||
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
|
||||
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
|
||||
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
|
||||
}
|
||||
|
||||
notificationGroup := adminAuthrized.Group("/notification")
|
||||
{
|
||||
// offline notifications
|
||||
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
|
||||
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
|
||||
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
|
||||
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
|
||||
}
|
||||
}
|
||||
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
var (
|
||||
TwoFactorIssuer = "Komari Monitor"
|
||||
)
|
||||
|
||||
func Generate2Fa() (string, image.Image, error) {
|
||||
otp, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: TwoFactorIssuer,
|
||||
AccountName: "komari",
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
img, err := otp.Image(250, 250)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return otp.Secret(), img, nil
|
||||
}
|
||||
|
||||
func Enable2Fa(uuid, secret string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", secret).Error
|
||||
}
|
||||
|
||||
func Verify2Fa(uuid, code string) (bool, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var user models.User
|
||||
err := db.Where("uuid = ?", uuid).First(&user).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if user.TwoFactor == "" {
|
||||
return false, nil // 用户未启用2FA
|
||||
}
|
||||
|
||||
valid := totp.Validate(code, user.TwoFactor)
|
||||
if !valid {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func Disable2Fa(uuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", "").Error
|
||||
}
|
||||
@@ -155,6 +155,12 @@ func UnbindExternalAccount(uuid string) error {
|
||||
|
||||
func UpdateUser(uuid string, name, password, sso_type *string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
// Check if user exists
|
||||
var existingUser models.User
|
||||
result := db.Where("uuid = ?", uuid).First(&existingUser)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("user not found: %s", uuid)
|
||||
}
|
||||
updates := make(map[string]interface{})
|
||||
if name != nil {
|
||||
updates["username"] = *name
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// CreateClipboard 创建剪贴板记录
|
||||
func CreateClipboard(cb *models.Clipboard) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(cb).Error
|
||||
}
|
||||
|
||||
// GetClipboardByID 根据ID获取剪贴板记录
|
||||
func GetClipboardByID(id int) (*models.Clipboard, error) {
|
||||
var cb models.Clipboard
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.First(&cb, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cb, nil
|
||||
}
|
||||
|
||||
// UpdateClipboardFields 更新剪贴板记录
|
||||
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteClipboard 删除剪贴板记录
|
||||
func DeleteClipboard(id int) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
// Check if record exists first
|
||||
var cb models.Clipboard
|
||||
if err := db.First(&cb, id).Error; err != nil {
|
||||
return err // Record not found or other error
|
||||
}
|
||||
return db.Delete(&cb).Error
|
||||
}
|
||||
|
||||
// DeleteClipboardBatch 批量删除剪贴板记录
|
||||
func DeleteClipboardBatch(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Where("id IN ?", ids).Delete(&models.Clipboard{}).Error
|
||||
}
|
||||
|
||||
// ListClipboard 列出所有剪贴板记录
|
||||
func ListClipboard() ([]models.Clipboard, error) {
|
||||
var list []models.Clipboard
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
|
||||
@@ -122,7 +124,13 @@ func GetDBInstance() *gorm.DB {
|
||||
var err error
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
//Logger: logger.Default.LogMode(logger.Silent),
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}
|
||||
|
||||
if utils.VersionHash == "unknown" {
|
||||
logConfig = &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
}
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
@@ -160,6 +168,9 @@ func GetDBInstance() *gorm.DB {
|
||||
&models.Record{},
|
||||
&models.Config{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create tables: %v", err)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
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 time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -23,6 +23,8 @@ type Config struct {
|
||||
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
|
||||
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type Client struct {
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
|
||||
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -39,6 +40,7 @@ type User struct {
|
||||
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
|
||||
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
|
||||
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
|
||||
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
// Notification 定义了通知相关的数据库模型
|
||||
type OfflineNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE"`
|
||||
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 分钟
|
||||
}
|
||||
|
||||
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
|
||||
type LoadNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;constraint:OnDelete:CASCADE"`
|
||||
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
|
||||
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
|
||||
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
|
||||
Window int `json:"window" gorm:"type:int;not null;default:300"` // 观测窗口(秒)
|
||||
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
|
||||
Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package notification
|
||||
@@ -69,7 +69,7 @@ func QuantileMean(values []float32) float32 {
|
||||
// 压缩数据库,针对每个ClientUUID,10分钟内的数据不动,24小时内的数据精简为每15分钟一条,3天为每30分钟一条,7天为每1小时一条,30天为每12小时一条。所有精简的数据是取 [0.02,0.98] 区间内的平均值
|
||||
func CompactRecord() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
log.Printf("Compacting records...")
|
||||
//log.Printf("Compacting records...")
|
||||
var clientUUIDs []string
|
||||
if err := db.Model(&models.Record{}).Distinct("client").Pluck("client", &clientUUIDs).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,11 +3,11 @@ module github.com/komari-monitor/komari
|
||||
go 1.23.2
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.5
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
@@ -26,6 +26,7 @@ require golang.org/x/net v0.39.0 // indirect
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
@@ -43,7 +44,7 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.27 // indirect
|
||||
@@ -51,6 +52,7 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.8.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
@@ -58,5 +60,6 @@ require (
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
@@ -15,8 +17,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
|
||||
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
@@ -53,8 +53,12 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
@@ -72,8 +76,12 @@ github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -111,8 +119,10 @@ golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
- [x] Web终端
|
||||
|
||||
- [ ] 一键部署脚本
|
||||
- [x] 一键部署脚本
|
||||
|
||||
- [ ] 远程命令执行
|
||||
- [x] 远程命令执行
|
||||
|
||||
## Serser
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
- [x] 命令行指令 chpasswd 强制更换密码
|
||||
|
||||
- [ ] Github单点登录、绑定、解绑
|
||||
- [x] Github单点登录、绑定、解绑
|
||||
|
||||
- [ ] 2FA
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/telegram"
|
||||
)
|
||||
|
||||
var (
|
||||
lastNotified = make(map[string]time.Time)
|
||||
lastOnlineNotified = make(map[string]time.Time)
|
||||
pendingOffline = make(map[string]time.Time)
|
||||
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
// OfflineNotification sends an offline notification for the client if enabled and not sent in cooldown period
|
||||
func OfflineNotification(clientID string) {
|
||||
client, err := clients.GetClientByUUID(clientID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
conf, _ := config.Get()
|
||||
if !conf.NotificationEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
noti_conf := models.OfflineNotification{
|
||||
Client: clientID,
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).FirstOrCreate(¬i_conf).Error
|
||||
if err != nil {
|
||||
log.Println("Failed to get or create offline notification config:", err)
|
||||
return
|
||||
}
|
||||
if !noti_conf.Enable {
|
||||
return
|
||||
}
|
||||
|
||||
cooldownDuration := time.Duration(noti_conf.Cooldown) * time.Second
|
||||
gracePeriod := time.Duration(noti_conf.GracePeriod) * time.Second
|
||||
if cooldownDuration <= 0 {
|
||||
cooldownDuration = 30 * time.Minute // default cooldown if not set
|
||||
}
|
||||
if gracePeriod <= 0 {
|
||||
gracePeriod = 5 * time.Minute // default grace period if not set
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if _, exists := pendingOffline[clientID]; exists {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
pendingOffline[clientID] = now
|
||||
mu.Unlock()
|
||||
|
||||
go func(start time.Time) {
|
||||
time.Sleep(gracePeriod)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
// if no longer pending (client reconnected), skip
|
||||
if ts, ok := pendingOffline[clientID]; !ok || !ts.Equal(start) {
|
||||
return
|
||||
}
|
||||
// check cooldown
|
||||
if last, ok := lastNotified[clientID]; ok && time.Since(last) < cooldownDuration {
|
||||
delete(pendingOffline, clientID)
|
||||
return
|
||||
}
|
||||
// record notification time and clear pending
|
||||
lastNotified[clientID] = time.Now()
|
||||
delete(pendingOffline, clientID)
|
||||
|
||||
message := fmt.Sprintf("🔴<code>%s</code> is offline", client.Name)
|
||||
go func(msg string) {
|
||||
if err := telegram.SendHTMLMessage(msg); err != nil {
|
||||
log.Println("Failed to send offline notification:", err)
|
||||
}
|
||||
}(message)
|
||||
}(now)
|
||||
}
|
||||
|
||||
// OnlineNotification sends an online notification for the client if enabled and not sent in cooldown period
|
||||
func OnlineNotification(clientID string) {
|
||||
client, err := clients.GetClientByUUID(clientID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
noti_conf := models.OfflineNotification{
|
||||
Client: clientID,
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).FirstOrCreate(¬i_conf).Error
|
||||
if err != nil {
|
||||
log.Println("Failed to get or create offline notification config:", err)
|
||||
return
|
||||
}
|
||||
if !noti_conf.Enable {
|
||||
return
|
||||
}
|
||||
cooldownDuration := time.Duration(noti_conf.Cooldown) * time.Second
|
||||
gracePeriod := time.Duration(noti_conf.GracePeriod) * time.Second
|
||||
if cooldownDuration <= 0 {
|
||||
cooldownDuration = 30 * time.Minute // default cooldown if not set
|
||||
}
|
||||
if gracePeriod <= 0 {
|
||||
gracePeriod = 5 * time.Minute // default grace period if not set
|
||||
}
|
||||
// clear any pending offline debounce
|
||||
mu.Lock()
|
||||
delete(pendingOffline, clientID)
|
||||
mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// if never went offline before, skip first online notification
|
||||
if _, wasOffline := lastNotified[clientID]; !wasOffline {
|
||||
return
|
||||
}
|
||||
// cooldown for online notifications
|
||||
if last, exists := lastOnlineNotified[clientID]; exists && now.Sub(last) < cooldownDuration {
|
||||
return
|
||||
}
|
||||
// send online notification
|
||||
message := fmt.Sprintf("🟢<code>%s</code> is online", client.Name)
|
||||
go func(msg string) {
|
||||
if err := telegram.SendHTMLMessage(msg); err != nil {
|
||||
log.Println("Failed to send online notification:", err)
|
||||
}
|
||||
}(message)
|
||||
|
||||
lastOnlineNotified[clientID] = now
|
||||
delete(lastNotified, clientID)
|
||||
}
|
||||
+42
-33
@@ -11,46 +11,55 @@ import (
|
||||
)
|
||||
|
||||
func SendMessage(message, msgType string) error {
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lastErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if !conf.TelegramEnabled || message == "" {
|
||||
return errors.New("telegram is disabled or message is empty")
|
||||
}
|
||||
if !conf.TelegramEnabled || message == "" {
|
||||
return errors.New("telegram is disabled or message is empty")
|
||||
}
|
||||
|
||||
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
|
||||
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("chat_id", conf.TelegramChatID)
|
||||
data.Set("text", message)
|
||||
if msgType != "text" {
|
||||
data.Set("parse_mode", msgType)
|
||||
}
|
||||
resp, err := http.PostForm(endpoint, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data := url.Values{}
|
||||
data.Set("chat_id", conf.TelegramChatID)
|
||||
data.Set("text", message)
|
||||
if msgType != "text" {
|
||||
data.Set("parse_mode", msgType)
|
||||
}
|
||||
resp, err := http.PostForm(endpoint, data)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("failed to send message: %v", err)
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram API returned non-OK status: %d", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
lastErr = fmt.Errorf("telegram API returned non-OK status: %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Ok bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
var result struct {
|
||||
Ok bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if !result.Ok {
|
||||
return fmt.Errorf("telegram API error: %s", result.Description)
|
||||
}
|
||||
if !result.Ok {
|
||||
lastErr = fmt.Errorf("telegram API error: %s", result.Description)
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// SendTextMessage sends a text message via Telegram API
|
||||
|
||||
Reference in new issue
Block a user