Compare commits

...
28 Commits
Author SHA1 Message Date
Akizon77 bb22a818bf fix(client): fill GeoIP region for v2 basic info 2026-06-10 01:12:02 +08:00
Akizon77 b66fe47487 fix(config): rename CORS toggle to origin check setting
Replace the legacy allow_cors setting with cors_origin_check_enabled
throughout server setup, public info output, and configuration constants.
This clarifies that the option controls API CORS origin validation rather
than broadly enabling CORS.
2026-06-09 17:27:06 +08:00
Akizon77 98122fa4d1 ci: generate release notes from GitHub 2026-06-09 16:46:33 +08:00
Akizon77 c3f65b1a32 fix go tests 2026-06-08 21:14:27 +08:00
Akizon77 7da616ce4c feat(security): add configurable CORS and origin checks
Add settings for allowed CORS origins and WebSocket origin validation so
deployments can restrict cross-origin access instead of using a global
wildcard policy.

Move CORS handling into the security middleware and update report cache
usage to use the dedicated report package.
2026-06-08 20:45:00 +08:00
airium 9d914e70b9 fix: harden traffic report accounting and validation (#542)
- Store per-record traffic deltas and sum them for scheduled reports
- Preserve traffic delta totals during long-term record compaction
- Compact only complete 15-minute buckets to avoid partial-bucket undercounts
- Serialize report saves per client so concurrent uploads cannot double-count deltas
- Enforce enabled traffic report cadence validation across edit and enable paths
- Add regression coverage for compaction boundaries, cadence validation, and client save locking
2026-06-08 11:26:13 +08:00
Akizon77 6f0624e9b4 Merge branch 'main' into codex/theme-configuration-types 2026-06-08 01:58:52 +08:00
Akizon77 3cc67e11cd fix: align theme redirect and html language handling 2026-06-08 01:53:28 +08:00
Akizon77 742a5e953e feat: support raw and redirect themes 2026-06-08 01:53:28 +08:00
Akizon77 3b82cee4b1 fix: align theme redirect and html language handling 2026-06-08 01:53:20 +08:00
Akizon77 2b010954a3 feat: support raw and redirect themes 2026-06-08 00:48:37 +08:00
Akizon77 6f23565517 refactor: isolate startup migrations 2026-06-08 00:01:32 +08:00
airium 7c2896c3fe fix(remote-exec): preserve command text during validation (#543)
Reject whitespace-only remote exec commands without trimming the submitted
command body, so multiline scripts and meaningful leading/trailing content are
preserved through dispatch.
2026-06-07 23:54:43 +08:00
Akizon77 721d61bc4e docs: update default theme build path 2026-06-06 22:59:38 +08:00
kylvenp 014b236745 feat: add network test rpc protocol structs 2026-06-06 21:34:48 +08:00
Akizon77 80c8d7e5ee Merge pull request #537 from airium/fix-http-development 2026-06-06 20:05:30 +08:00
Akizon77 8423881cc4 Merge pull request #541 from airium/support-phy-cpu-count 2026-06-06 19:59:16 +08:00
Akizon77 a1d644977c Merge pull request #538 from airium/fix-theme-path 2026-06-06 19:59:05 +08:00
Akizon77 301fb536f2 Merge pull request #539 from aijwfsibari/main 2026-06-06 19:53:17 +08:00
airium 49a8d772c8 feat: support physical CPU core count 2026-06-06 19:15:03 +08:00
aijwfsibari a9adae29d2 add Traffic report patch 2026-06-05 15:04:21 +08:00
airium d6e23baa19 fix: correct defaultTheme path across CI and docs
The Go embed directive in web/public/public.go resolves defaultTheme
relative to the package directory (web/public/defaultTheme/),
but all CI workflows and documentation referenced public/defaultTheme/
which would have caused builds to fail or embed an empty directory.
2026-06-02 13:24:23 +08:00
airium 658e7603f8 fix: allow insecure session cookies in development build 2026-06-02 02:31:01 +08:00
Akizon77 fb0b9b9910 feat(agent): add v2 pull events for queued agent tasks
Introduce agent.pull protocol types with event metadata and ACK fields.
Route ping dispatch through the agent API so tasks can be queued for
pull-based clients instead of only sent over active WebSocket sessions.
2026-05-31 19:01:02 +08:00
Akizon77 e149e8b8b7 refactor: remove legacy client APIs and support v2 pings
Remove deprecated client database helpers tied to common protocol types and
update report handling to use protocol/v1 models. Send ping tasks with the
v2 JSON-RPC payload for v2 clients while preserving existing behavior for
legacy websocket clients.
2026-05-31 16:50:04 +08:00
Akizon77 d2cc1503de refactor: centralize scheduled jobs with corn package
Replace ad-hoc ticker loops with named cron-based scheduled tasks for cleanup,
minute reporting, and expiration checks. Add scheduler shutdown handling and
log errors when schedules fail to reload or register.

Update imports to use the relocated pkg/config package across affected code.
2026-05-31 15:54:23 +08:00
Akizon77 f86803feff refactor(web): move HTTP route setup out of server
Relocate API routing, OAuth, and Nezha compatibility wiring into the web package to keep server startup focused on initialization concerns and improve code organization.
2026-05-31 15:35:47 +08:00
flyflas a2e3c7218d Merge pull request #525 from flyflas/feat/addXtermjsSetting
Feat/add xtermjs setting
2026-05-31 14:14:16 +08:00
161 changed files with 5975 additions and 1718 deletions

No files matched your search

+10 -10
View File
@@ -65,20 +65,20 @@ jobs:
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/defaultTheme/dist
rm -rf public/defaultTheme/dist/*
cp -r web/dist/* public/defaultTheme/dist/
cp -f web/komari-theme.json public/defaultTheme/
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
+12 -12
View File
@@ -16,25 +16,25 @@ jobs:
#- name: Create dummy index.html
# run: |
# mkdir -p public/defaultTheme/dist
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/defaultTheme/dist/index.html
# mkdir -p web/public/defaultTheme/dist
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > web/public/defaultTheme/dist/index.html
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/defaultTheme/dist
rm -rf public/defaultTheme/dist/*
cp -r web/dist/* public/defaultTheme/dist/
cp -f web/komari-theme.json public/defaultTheme/
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
+10 -10
View File
@@ -28,20 +28,20 @@ jobs:
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/defaultTheme/dist
rm -rf public/defaultTheme/dist/*
cp -r web/dist/* public/defaultTheme/dist/
cp -f web/komari-theme.json public/defaultTheme/
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
@@ -0,0 +1,62 @@
name: Generate Release Notes
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to regenerate notes for."
required: true
type: string
permissions:
contents: write
jobs:
update-release-notes:
runs-on: ubuntu-latest
steps:
- name: Generate and apply release notes
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || core.getInput("tag");
if (!tag) {
core.setFailed("No release tag was provided.");
return;
}
const { owner, repo } = context.repo;
const release = context.payload.release?.tag_name === tag
? context.payload.release
: (await github.rest.repos.getReleaseByTag({ owner, repo, tag })).data;
const generated = await github.request(
"POST /repos/{owner}/{repo}/releases/generate-notes",
{
owner,
repo,
tag_name: tag,
target_commitish: release.target_commitish || undefined,
},
);
const body = generated.data.body?.trim();
if (!body) {
core.setFailed(`GitHub returned empty release notes for ${tag}.`);
return;
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
body,
});
await core.summary
.addHeading("Release Notes")
.addRaw(`Updated release notes for ${tag}.`)
.write();
+10 -10
View File
@@ -31,20 +31,20 @@ jobs:
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/defaultTheme/dist
rm -rf public/defaultTheme/dist/*
cp -r web/dist/* public/defaultTheme/dist/
cp -f web/komari-theme.json public/defaultTheme/
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
+10 -10
View File
@@ -68,20 +68,20 @@ jobs:
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
git clone https://github.com/komari-monitor/komari-web komari-web
cd komari-web
npm install
npm run build
cd ..
mkdir -p public/defaultTheme/dist
rm -rf public/defaultTheme/dist/*
cp -r web/dist/* public/defaultTheme/dist/
cp -f web/komari-theme.json public/defaultTheme/
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
mkdir -p web/public/defaultTheme/dist
rm -rf web/public/defaultTheme/dist/*
cp -r komari-web/dist/* web/public/defaultTheme/dist/
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
# Compatibility: ensure both filenames exist if only one is present
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Set up Go
uses: actions/setup-go@v5
+2 -1
View File
@@ -33,4 +33,5 @@ go.work.sum
.env
# build
komari
komari
.codex
+1 -1
View File
@@ -93,7 +93,7 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
Copy the static files generated in step 1 to the `/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/public/defaultTheme`.
Copy the static files generated in step 1 to the `/web/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/web/public/defaultTheme`.
```bash
go build -o komari
```
-85
View File
@@ -1,85 +0,0 @@
package client
import (
"net"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/gin-gonic/gin"
)
func getClientIPType(ip net.IP) int {
// 0:ipv4 1:ipv6 -1:错误的输入
if ip == nil {
return -1
}
if ip.To4() == nil {
return 1
} else {
return 0
}
}
func UploadBasicInfo(c *gin.Context) {
var cbi = map[string]interface{}{}
if err := c.ShouldBindJSON(&cbi); err != nil {
c.JSON(400, gin.H{"status": "error", "error": "Invalid or missing data"})
return
}
token := c.Query("token")
uuid, err := clients.GetClientUUIDByToken(token)
if uuid == "" || err != nil {
c.JSON(400, gin.H{"status": "error", "error": "Invalid token"})
return
}
cbi["uuid"] = uuid
// 如果没有传入 IP 信息,尝试从请求中获取
if (func() bool {
if v4, ok := cbi["ipv4"].(string); !ok || v4 == "" {
if v6, ok := cbi["ipv6"].(string); !ok || v6 == "" {
return true
}
}
return false
})() {
ipStr := c.ClientIP()
ip := net.ParseIP(ipStr)
ipType := getClientIPType(ip)
switch ipType {
case 0:
cbi["ipv4"] = ipStr
case 1:
cbi["ipv6"] = ipStr
default:
break
}
}
if cfg, err := config.GetAs[bool](config.GeoIpEnabledKey); err == nil && cfg {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoInfo(ip4)
if ip4_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
}
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
ip6 := net.ParseIP(ipv6)
ip6_record, _ := geoip.GetGeoInfo(ip6)
if ip6_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
}
}
}
if err := clients.SaveClientInfo(cbi); err != nil {
c.JSON(500, gin.H{"status": "error", "error": err})
return
}
c.JSON(200, gin.H{"status": "success"})
}
+33 -6
View File
@@ -1,14 +1,41 @@
package flags
import "strings"
const (
DatabaseTypeSQLite = "sqlite"
)
var (
// 数据库配置
DatabaseType string // 数据库类型:sqlite, mysql
DatabaseType string // 数据库类型:sqlite
DatabaseFile string // SQLite数据库文件路径
DatabaseHost string // MySQL/其他数据库主机地址
DatabasePort string // MySQL/其他数据库端口
DatabaseUser string // MySQL/其他数据库用户名
DatabasePass string // MySQL/其他数据库密码
DatabaseName string // MySQL/其他数据库名称
DatabaseHost string // 保留的兼容参数,当前未使用
DatabasePort string // 保留的兼容参数,当前未使用
DatabaseUser string // 保留的兼容参数,当前未使用
DatabasePass string // 保留的兼容参数,当前未使用
DatabaseName string // 保留的兼容参数,当前未使用
Listen string
)
func NormalizeDatabaseType(databaseType string) string {
databaseType = strings.ToLower(strings.TrimSpace(databaseType))
if databaseType == "" {
return DatabaseTypeSQLite
}
return databaseType
}
func ApplyDatabaseTypeNormalization() string {
DatabaseType = NormalizeDatabaseType(DatabaseType)
return DatabaseType
}
func IsSQLite() bool {
return NormalizeDatabaseType(DatabaseType) == DatabaseTypeSQLite
}
func SupportedDatabaseTypes() string {
return DatabaseTypeSQLite
}
+20
View File
@@ -0,0 +1,20 @@
package flags
import "testing"
func TestNormalizeDatabaseType(t *testing.T) {
tests := map[string]string{
"": DatabaseTypeSQLite,
"sqlite": DatabaseTypeSQLite,
" SQLite ": DatabaseTypeSQLite,
"SQLITE": DatabaseTypeSQLite,
"postgres": "postgres",
" postgres": "postgres",
}
for input, want := range tests {
if got := NormalizeDatabaseType(input); got != want {
t.Fatalf("NormalizeDatabaseType(%q) = %q, want %q", input, got, want)
}
}
}
+3 -8
View File
@@ -4,9 +4,8 @@ import (
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
var PermitPasswordLoginCmd = &cobra.Command{
@@ -14,12 +13,8 @@ var PermitPasswordLoginCmd = &cobra.Command{
Short: "Force permit password login",
Long: `Force permit password login`,
Run: func(cmd *cobra.Command, args []string) {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.Config{}).Where("id = ?", 1).
Update("disable_password_login", false).Error
})
if err != nil {
dbcore.GetDBInstance()
if err := config.Set(config.DisablePasswordLoginKey, false); err != nil {
cmd.Println("Error:", err)
os.Exit(1)
}
+9 -6
View File
@@ -48,11 +48,14 @@ func Execute() {
func init() {
// 设置命令行参数,提供环境变量作为默认值
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite, mysql) [env: KOMARI_DB_TYPE]")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite) [env: KOMARI_DB_TYPE]")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "MySQL/Other database host address [env: KOMARI_DB_HOST]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "MySQL/Other database port [env: KOMARI_DB_PORT]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "MySQL/Other database username [env: KOMARI_DB_USER]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "MySQL/Other database password [env: KOMARI_DB_PASS]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "MySQL/Other database name [env: KOMARI_DB_NAME]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "Reserved database host parameter [env: KOMARI_DB_HOST]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "Reserved database port parameter [env: KOMARI_DB_PORT]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "Reserved database username parameter [env: KOMARI_DB_USER]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "Reserved database password parameter [env: KOMARI_DB_PASS]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "Reserved database name parameter [env: KOMARI_DB_NAME]")
for _, name := range []string{"db-host", "db-port", "db-user", "db-pass", "db-name"} {
_ = RootCmd.PersistentFlags().MarkHidden(name)
}
}
+51 -252
View File
@@ -11,20 +11,10 @@ import (
"time"
"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"
"github.com/komari-monitor/komari/api/jsonRpc"
public_api "github.com/komari-monitor/komari/api/public"
"github.com/komari-monitor/komari/api/terminal"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
@@ -33,21 +23,21 @@ import (
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
logutil "github.com/komari-monitor/komari/utils/log"
"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/web/nezha"
"github.com/komari-monitor/komari/web/oauth"
report_cache "github.com/komari-monitor/komari/web/report"
"github.com/komari-monitor/komari/web/router"
"github.com/komari-monitor/komari/web/security"
"github.com/spf13/cobra"
)
var (
DynamicCorsEnabled bool = false
)
var ServerCmd = &cobra.Command{
Use: "server",
Short: "Start the server",
@@ -73,7 +63,7 @@ func RunServer() {
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
conf, err := config.GetManyAs[config.Legacy]()
conf, err := config.GetManyAs[config.Settings]()
if err != nil {
log.Fatal(err)
}
@@ -85,7 +75,7 @@ func RunServer() {
if conf.NezhaCompatEnabled {
go func() {
if err := StartNezhaCompat(conf.NezhaCompatListen); err != nil {
if err := nezha.StartNezhaCompat(conf.NezhaCompatListen); err != nil {
log.Printf("Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
}
@@ -112,12 +102,12 @@ func RunServer() {
if ok, t := config.IsChangedT[bool](event, config.NezhaCompatEnabledKey); ok {
if t {
l, _ := config.GetAs[string](config.NezhaCompatListenKey)
if err := StartNezhaCompat(l); err != nil {
if err := nezha.StartNezhaCompat(l); err != nil {
log.Printf("start Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
}
} else {
if err := StopNezhaCompat(); err != nil {
if err := nezha.StopNezhaCompat(); err != nil {
log.Printf("stop Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
}
@@ -134,13 +124,7 @@ func RunServer() {
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
// 动态 CORS 中间件
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
if ok, t := config.IsChangedT[bool](event, config.AllowCorsKey); ok {
DynamicCorsEnabled = t
}
if event.IsChanged(config.GeoIpProviderKey) {
go geoip.InitGeoIp()
}
@@ -150,21 +134,7 @@ func RunServer() {
}
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "43200") // 12 hours
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
}
c.Next()
})
r.Use(security.CorsMiddleware(conf.CorsOriginCheckEnabled, conf.CorsAllowedOrigins))
r.Use(api.IdentityMiddleware())
r.Use(api.PrivateSiteMiddleware())
@@ -176,175 +146,7 @@ func RunServer() {
c.Next()
})
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", public_api.Login)
r.GET("/api/me", public_api.GetMe)
r.GET("/api/clients", api.GetClients)
r.GET("/api/nodes", public_api.GetNodesInformation)
r.GET("/api/public", public_api.GetPublicSettings)
r.GET("/api/oauth", public_api.OAuth)
r.GET("/api/oauth_callback", public_api.OAuthCallback)
r.GET("/api/logout", public_api.Logout)
r.GET("/api/version", public_api.GetVersion)
r.GET("/api/recent/:uuid", public_api.GetClientRecentRecords)
r.GET("/api/records/load", public_api.GetRecordsByUUID)
r.GET("/api/records/ping", public_api.GetPingRecords)
r.GET("/api/task/ping", public_api.GetPublicPingTasks)
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
r.GET("/api/mjpeg_live", public_api.MjpegLiveHandler)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", api.RequireRole(api.RoleAdmin, api.RoleClient))
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", terminal.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
tokenAuthrized.GET("/ping/tasks", client.GetPingTasks)
tokenAuthrized.POST("/ping/result", client.UploadPingResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.RequireRole(api.RoleAdmin))
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
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)
settingsGroup.GET("/cloudflared", admin.GetCloudflaredStatus)
settingsGroup.POST("/cloudflared/start", admin.StartCloudflared)
settingsGroup.POST("/cloudflared/stop", admin.StopCloudflared)
settingsGroup.POST("/cloudflared/remove-token", admin.RemoveCloudflaredToken)
}
// themes
themeGroup := adminAuthrized.Group("/theme")
{
themeGroup.PUT("/upload", admin.UploadTheme)
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/import", admin.ImportTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", terminal.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
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)
loadAlertGroup := notificationGroup.Group("/load")
{
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
loadAlertGroup.POST("/add", notification.AddLoadNotification)
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
}
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
pingTaskGroup.POST("/order", admin.OrderPingTask)
}
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
router.Register(r)
srv := &http.Server{
Addr: flags.Listen,
@@ -370,18 +172,6 @@ func RunServer() {
}
func InitDatabase() {
// // 打印数据库类型和连接信息
// if flags.DatabaseType == "mysql" {
// log.Printf("使用 MySQL 数据库连接: %s@%s:%s/%s",
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_HOST=%s] [KOMARI_DB_PORT=%s] [KOMARI_DB_USER=%s] [KOMARI_DB_NAME=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_HOST"), os.Getenv("KOMARI_DB_PORT"),
// os.Getenv("KOMARI_DB_USER"), os.Getenv("KOMARI_DB_NAME"))
// } else {
// log.Printf("使用 SQLite 数据库文件: %s", flags.DatabaseFile)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_FILE=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_FILE"))
// }
var count int64 = 0
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
user, passwd, err := accounts.CreateDefaultAdminAccount()
@@ -394,41 +184,50 @@ func InitDatabase() {
// #region 定时任务
func DoScheduledWork() {
if err := tasks.MigrateAllClientsExpansion(); err != nil {
log.Println("Failed to migrate ping task all_clients expansion:", err)
if err := tasks.ReloadPingSchedule(); err != nil {
log.Println("Failed to reload ping schedule:", err)
}
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
log.Println("Failed to reload load notification schedule:", err)
}
tasks.ReloadPingSchedule()
d_notification.ReloadLoadNotificationSchedule()
ticker := time.NewTicker(time.Minute * 30)
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
go notifier.CheckExpireScheduledWork()
for {
cfg, _ := config.GetManyAs[config.Legacy]()
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
auditlog.RemoveOldLogs()
accounts.RemoveExpiredSessions()
case <-minute.C:
api.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
// 每分钟检查一次流量提醒
go notifier.CheckTraffic()
}
}
if err := corn.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
log.Println("Failed to add cleanup scheduled task:", err)
}
if err := corn.AddFunc("records:minute", "@every 1m", minuteScheduledWork); err != nil {
log.Println("Failed to add minute scheduled task:", err)
}
if err := corn.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
log.Println("Failed to add expire notification scheduled task:", err)
}
notifier.InitTrafficReportSchedule()
}
func cleanupScheduledData() {
cfg, _ := config.GetManyAs[config.Settings]()
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
auditlog.RemoveOldLogs()
accounts.RemoveExpiredSessions()
}
func minuteScheduledWork() {
cfg, _ := config.GetManyAs[config.Settings]()
report_cache.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
// 每分钟检查一次流量提醒
notifier.CheckTraffic()
}
func OnShutdown() {
auditlog.Log("", "", "server is shutting down", "info")
corn.StopAll()
cloudflared.Shutdown()
}
-135
View File
@@ -1,135 +0,0 @@
package common
import "time"
type Message struct {
Type string `json:"type"`
Content string `json:"content"`
Sender string `json:"sender"`
Timestamp int64 `json:"timestamp"`
}
type ClientConfig struct {
ClientUUID string `json:"client_uuid" gorm:"type:uuid;primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
CPU bool `json:"cpu" gorm:"default:true"`
GPU bool `json:"gpu" gorm:"default:true"`
RAM bool `json:"ram" gorm:"default:true"`
SWAP bool `json:"swap" gorm:"default:true"`
LOAD bool `json:"load" gorm:"default:true"`
UPTIME bool `json:"uptime" gorm:"default:true"`
TEMP bool `json:"temp" gorm:"default:true"`
OS bool `json:"os" gorm:"default:true"`
DISK bool `json:"disk" gorm:"default:true"`
NET bool `json:"net" gorm:"default:true"`
PROCESS bool `json:"process" gorm:"default:true"`
Connections bool `json:"connections" gorm:"default:true"`
Interval int `json:"interval" gorm:"default:3;check:Interval >= 1"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ClientInfo stores static client information
// Deprecated: Use models.Client instead.
type ClientInfo struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Name string `json:"name" gorm:"type:varchar(100);not null"`
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"`
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time
UpdatedAt time.Time
}
type IPAddress struct {
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
}
type Report struct {
UUID string `json:"uuid,omitempty"`
CPU CPUReport `json:"cpu"`
Ram RamReport `json:"ram"`
Swap RamReport `json:"swap"`
Load LoadReport `json:"load"`
Disk DiskReport `json:"disk"`
Network NetworkReport `json:"network"`
Connections ConnectionsReport `json:"connections"`
GPU *GPUDetailReport `json:"gpu,omitempty"` // 新增GPU详细信息
Uptime int64 `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
Method string `json:"method,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type CPUReport struct {
Name string `json:"name,omitempty"`
Cores int `json:"cores,omitempty"`
Arch string `json:"arch,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
// GPUDetailReport 详细GPU信息报告
type GPUDetailReport struct {
Count int `json:"count"` // GPU数量
AverageUsage float64 `json:"average_usage"` // 平均使用率
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
}
// GPUDeviceInfo 单个GPU设备信息
type GPUDeviceInfo struct {
Name string `json:"name"` // GPU型号名称
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
Utilization float64 `json:"utilization"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// 保持向后兼容的旧GPUReport结构
type GPUReport struct {
Name string `json:"name,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type RamReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type LoadReport struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
type DiskReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type NetworkReport struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
TotalUp int64 `json:"totalUp"`
TotalDown int64 `json:"totalDown"`
}
type ConnectionsReport struct {
TCP int `json:"tcp"`
UDP int `json:"udp"`
}
+1 -1
View File
@@ -6,10 +6,10 @@ import (
"net"
"time"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
+59 -122
View File
@@ -1,32 +1,21 @@
package clients
import (
"encoding/json"
"fmt"
"log"
"math"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fmt"
"github.com/google/uuid"
)
// Deprecated: DeleteClientConfig is deprecated and will be removed in a future release. Use DeleteClient instead.
func DeleteClientConfig(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&common.ClientConfig{ClientUUID: clientUuid}).Error
if err != nil {
return err
}
return nil
}
func DeleteClient(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
@@ -36,79 +25,6 @@ func DeleteClient(clientUuid string) error {
return nil
}
// Deprecated: UpdateOrInsertBasicInfo is deprecated and will be removed in a future release. Use SaveClientInfo instead.
func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
db := dbcore.GetDBInstance()
updates := make(map[string]interface{})
if cbi.Name != "" {
updates["name"] = cbi.Name
}
if cbi.CpuName != "" {
updates["cpu_name"] = cbi.CpuName
}
if cbi.Arch != "" {
updates["arch"] = cbi.Arch
}
if cbi.CpuCores > 0 || cbi.CpuCores < math.MaxInt-1 {
updates["cpu_cores"] = cbi.CpuCores
}
if cbi.OS != "" {
updates["os"] = cbi.OS
}
if cbi.GpuName != "" {
updates["gpu_name"] = cbi.GpuName
}
if cbi.IPv4 != "" {
updates["ipv4"] = cbi.IPv4
}
if cbi.IPv6 != "" {
updates["ipv6"] = cbi.IPv6
}
if cbi.Region != "" {
updates["region"] = cbi.Region
}
if cbi.Remark != "" {
updates["remark"] = cbi.Remark
}
updates["mem_total"] = cbi.MemTotal
updates["swap_total"] = cbi.SwapTotal
updates["disk_total"] = cbi.DiskTotal
updates["version"] = cbi.Version
updates["updated_at"] = time.Now()
// 转换为更新Client表
client := models.Client{
UUID: cbi.UUID,
}
err := db.Model(&client).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(updates),
}).Create(map[string]interface{}{
"uuid": cbi.UUID,
"name": cbi.Name,
"cpu_name": cbi.CpuName,
"arch": cbi.Arch,
"cpu_cores": cbi.CpuCores,
"os": cbi.OS,
"gpu_name": cbi.GpuName,
"ipv4": cbi.IPv4,
"ipv6": cbi.IPv6,
"region": cbi.Region,
"remark": cbi.Remark,
"mem_total": cbi.MemTotal,
"swap_total": cbi.SwapTotal,
"disk_total": cbi.DiskTotal,
"version": cbi.Version,
"updated_at": time.Now(),
}).Error
if err != nil {
return err
}
return nil
}
func SaveClientInfo(update map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := update["uuid"].(string)
@@ -123,27 +39,73 @@ func SaveClientInfo(update map[string]interface{}) error {
update["updated_at"] = time.Now()
checkInt64 := func(name string, val float64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
toFloat64 := func(value interface{}) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int8:
return float64(typed), true
case int16:
return float64(typed), true
case int32:
return float64(typed), true
case int64:
return float64(typed), true
case uint:
return float64(typed), true
case uint8:
return float64(typed), true
case uint16:
return float64(typed), true
case uint32:
return float64(typed), true
case uint64:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
if err != nil {
return 0, false
}
return parsed, true
default:
return 0, false
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
}
checkOptionalInt := func(name, key string, maxValue float64) error {
value, exists := update[key]
if !exists || value == nil {
return nil
}
numericValue, ok := toFloat64(value)
if !ok {
return fmt.Errorf("%s must be a valid number", name)
}
if numericValue < 0 || numericValue > maxValue {
return fmt.Errorf("%s must be a valid non-negative number: %v", name, value)
}
return nil
}
verify := func(update map[string]interface{}) error {
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
}
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
if err := checkOptionalInt("Cpu.Cores", "cpu_cores", math.MaxInt-1); err != nil {
return err
}
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
if err := checkOptionalInt("Cpu.PhysicalCores", "cpu_physical_cores", math.MaxInt-1); err != nil {
return err
}
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
if err := checkOptionalInt("Ram.Total", "mem_total", math.MaxInt64-1); err != nil {
return err
}
if err := checkOptionalInt("Swap.Total", "swap_total", math.MaxInt64-1); err != nil {
return err
}
if err := checkOptionalInt("Disk.Total", "disk_total", math.MaxInt64-1); err != nil {
return err
}
return nil
@@ -160,16 +122,6 @@ func SaveClientInfo(update map[string]interface{}) error {
return nil
}
// 更新客户端设置
func UpdateClientConfig(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
err := db.Save(&config).Error
if err != nil {
return err
}
return nil
}
func EditClientName(clientUUID, clientName string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
@@ -179,21 +131,6 @@ func EditClientName(clientUUID, clientName string) error {
return nil
}
/*
// UpdateClientByUUID 更新指定 UUID 的客户端配置
func UpdateClientByUUID(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
result := db.Model(&common.ClientConfig{}).Where("client_uuid = ?", config.ClientUUID).Updates(config)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
*/
func EditClientToken(clientUUID, token string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
+64 -9
View File
@@ -2,17 +2,58 @@ package clients
import (
"encoding/json"
"errors"
"fmt"
"math"
"sync"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
v1 "github.com/komari-monitor/komari/protocol/v1"
"gorm.io/gorm"
)
var reportSaveLocks sync.Map
func getReportSaveLock(clientUUID string) *sync.Mutex {
lock, _ := reportSaveLocks.LoadOrStore(clientUUID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func computeTrafficDelta(current, previous int64) int64 {
if previous < 0 {
return 0
}
if current >= previous {
return current - previous
}
// Counter reset or reboot: treat the new counter value as traffic since reset.
return current
}
func getLatestTrafficRecord(tx *gorm.DB, clientUUID string) (*models.Record, error) {
var record models.Record
err := tx.Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
if err == nil {
return &record, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
err = tx.Table("records_long_term").Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
if err == nil {
return &record, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return nil, nil
}
// Report 表示客户端报告数据
// SaveReport 保存报告数据
func SaveReport(uuid string, data map[string]interface{}) (err error) {
@@ -40,20 +81,20 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
return client.UUID, nil
}
func ParseReport(data map[string]interface{}) (report common.Report, err error) {
func ParseReport(data map[string]interface{}) (report v1.Report, err error) {
jsonData, err := json.Marshal(data)
if err != nil {
return common.Report{}, err
return v1.Report{}, err
}
err = json.Unmarshal(jsonData, &report)
if err != nil {
return common.Report{}, err
return v1.Report{}, err
}
return report, nil
}
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report common.Report) error {
func ReportVerify(report v1.Report) error {
// 防止输入不合理范围
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
return fmt.Errorf("CPU.Usage must be between 0 and 100")
@@ -137,11 +178,14 @@ func ReportVerify(report common.Report) error {
}
// SaveClientReport 保存客户端报告到 Record 表
func SaveClientReport(clientUUID string, report common.Report) (err error) {
func SaveClientReport(clientUUID string, report v1.Report) (err error) {
db := dbcore.GetDBInstance()
lock := getReportSaveLock(clientUUID)
lock.Lock()
defer lock.Unlock()
if err := ReportVerify(report); err != nil {
return fmt.Errorf("failed to save Record: %v", err)
return fmt.Errorf("failed to save Record: %w", err)
}
// 保存GPU详细记录到独立表
@@ -159,7 +203,7 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
Temperature: gpu.Temperature,
}
if err := db.Create(&gpuRecord).Error; err != nil {
return fmt.Errorf("failed to save GPU record: %v", err)
return fmt.Errorf("failed to save GPU record: %w", err)
}
}
}
@@ -187,6 +231,8 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
NetOut: report.Network.Up,
NetTotalUp: report.Network.TotalUp,
NetTotalDown: report.Network.TotalDown,
TrafficUp: 0,
TrafficDown: 0,
Process: report.Process,
Connections: report.Connections.TCP,
ConnectionsUdp: report.Connections.UDP,
@@ -195,9 +241,18 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
// 使用事务确保 Record 和 ClientsInfo 一致性
err = db.Transaction(func(tx *gorm.DB) error {
previous, err := getLatestTrafficRecord(tx, clientUUID)
if err != nil {
return fmt.Errorf("failed to load previous Record: %w", err)
}
if previous != nil {
Record.TrafficUp = computeTrafficDelta(report.Network.TotalUp, previous.NetTotalUp)
Record.TrafficDown = computeTrafficDelta(report.Network.TotalDown, previous.NetTotalDown)
}
// 保存 Record
if err := tx.Create(&Record).Error; err != nil {
return fmt.Errorf("failed to save Record: %v", err)
return fmt.Errorf("failed to save Record: %w", err)
}
return nil
})
+82
View File
@@ -0,0 +1,82 @@
package clients
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestComputeTrafficDeltaHandlesZeroAndReset(t *testing.T) {
tests := []struct {
name string
current int64
previous int64
want int64
}{
{name: "previous zero counts current delta", current: 120, previous: 0, want: 120},
{name: "monotonic counter uses difference", current: 250, previous: 200, want: 50},
{name: "counter reset uses current", current: 15, previous: 250, want: 15},
{name: "negative previous remains guarded", current: 15, previous: -1, want: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, computeTrafficDelta(test.current, test.previous))
})
}
}
func TestReportSaveLockSerializesSameClient(t *testing.T) {
clientUUID := "client-lock-same"
lock := getReportSaveLock(clientUUID)
lock.Lock()
defer lock.Unlock()
acquired := make(chan struct{})
go func() {
secondLock := getReportSaveLock(clientUUID)
secondLock.Lock()
defer secondLock.Unlock()
close(acquired)
}()
select {
case <-acquired:
t.Fatal("same-client report save lock was acquired while already held")
case <-time.After(20 * time.Millisecond):
}
lock.Unlock()
select {
case <-acquired:
case <-time.After(time.Second):
t.Fatal("same-client report save lock was not released")
}
lock.Lock()
}
func TestReportSaveLockAllowsDifferentClients(t *testing.T) {
firstLock := getReportSaveLock("client-lock-a")
firstLock.Lock()
defer firstLock.Unlock()
var waitGroup sync.WaitGroup
waitGroup.Add(1)
acquired := make(chan struct{})
go func() {
defer waitGroup.Done()
secondLock := getReportSaveLock("client-lock-b")
secondLock.Lock()
defer secondLock.Unlock()
close(acquired)
}()
select {
case <-acquired:
case <-time.After(time.Second):
t.Fatal("different-client report save lock should not block")
}
waitGroup.Wait()
}
+10 -209
View File
@@ -2,7 +2,6 @@ package dbcore
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
@@ -13,9 +12,9 @@ import (
"time"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/migrations"
logutil "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -166,193 +165,6 @@ func unzipToDir(zipPath, dstDir string) error {
return nil
}
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
func mergeClientInfo(db *gorm.DB) {
var clientInfos []common.ClientInfo
if err := db.Find(&clientInfos).Error; err != nil {
log.Printf("Failed to read ClientInfo table: %v", err)
return
}
for _, info := range clientInfos {
var client models.Client
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
continue
}
// 更新Client记录
client.Name = info.Name
client.CpuName = info.CpuName
client.Virtualization = info.Virtualization
client.Arch = info.Arch
client.CpuCores = info.CpuCores
client.OS = info.OS
client.GpuName = info.GpuName
client.IPv4 = info.IPv4
client.IPv6 = info.IPv6
client.Region = info.Region
client.Remark = info.Remark
client.PublicRemark = info.PublicRemark
client.MemTotal = info.MemTotal
client.SwapTotal = info.SwapTotal
client.DiskTotal = info.DiskTotal
client.Version = info.Version
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
client.ExpiredAt = models.FromTime(info.ExpiredAt)
// Save updated Client record
if err := db.Save(&client).Error; err != nil {
log.Printf("Failed to update Client record: %v", err)
continue
}
}
// Backup and rename old table after migration
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
log.Printf("Failed to backup ClientInfo table: %v", err)
return
}
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
}
func MergeDatabase(db *gorm.DB) {
if db.Migrator().HasTable("client_infos") {
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
mergeClientInfo(db)
}
if db.Migrator().HasColumn(&models.Config{}, "allow_cros") {
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
}
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
db.Migrator().DropTable(&models.LoadNotification{})
}
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 (
instance *gorm.DB
once sync.Once
@@ -412,8 +224,8 @@ func GetDBInstance() *gorm.DB {
}
// 根据数据库类型选择不同的连接方式
switch flags.DatabaseType {
case "sqlite", "":
switch flags.ApplyDatabaseTypeNormalization() {
case flags.DatabaseTypeSQLite:
// SQLite 连接
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
if err != nil {
@@ -428,35 +240,24 @@ func GetDBInstance() *gorm.DB {
instance.Exec("PRAGMA cache_size = -65536;")
instance.Exec("PRAGMA temp_store = MEMORY;")
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
// case "mysql":
// // MySQL 连接
// dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
// flags.DatabaseUser,
// flags.DatabasePass,
// flags.DatabaseHost,
// flags.DatabasePort,
// flags.DatabaseName)
// instance, err = gorm.Open(mysql.Open(dsn), logConfig)
// if err != nil {
// log.Fatalf("Failed to connect to MySQL database: %v", err)
// }
// log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
default:
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
log.Fatalf("Unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
}
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
log.Fatalf("Failed to run startup migrations: %v", err)
}
config.SetDb(instance)
MergeDatabase(instance)
// 自动迁移模型
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Record{},
&models.GPURecord{},
// &models.Config{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.TrafficReportNotification{},
&models.PingRecord{},
&models.PingTask{},
&models.OidcProvider{},
@@ -493,7 +294,7 @@ func GetDBInstance() *gorm.DB {
}
// Manually create composite indexes
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
if flags.IsSQLite() {
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_client_time ON records(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_lt_client_time ON records_long_term(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_client_time ON gpu_records(client, time)")
-42
View File
@@ -1,42 +0,0 @@
package models
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
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
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" gorm:"default: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
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
// OAuth 配置
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"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt LocalTime
UpdatedAt LocalTime
}
@@ -8,4 +8,7 @@ const (
Login = "Login"
Alert = "Alert"
Traffic = "Traffic"
DReport = "DReport" // 日报
WReport = "WReport" // 周报
MReport = "MReport" // 月报
)
+26 -11
View File
@@ -15,6 +15,7 @@ type Client struct {
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
CpuPhysicalCores int `json:"cpu_physical_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)"`
@@ -87,6 +88,8 @@ type Record struct {
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"`
TrafficUp int64 `json:"traffic_up" gorm:"type:bigint"`
TrafficDown int64 `json:"traffic_down" gorm:"type:bigint"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
@@ -95,14 +98,14 @@ type Record struct {
// GPURecord logs individual GPU metrics over time
type GPURecord struct {
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
Time LocalTime `json:"time" gorm:"index"` // 记录时间
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
Time LocalTime `json:"time" gorm:"index"` // 记录时间
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// StringArray represents a slice of strings stored as JSON in the database
@@ -110,9 +113,21 @@ type GPURecord struct {
type StringArray []string
func (sa *StringArray) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to scan StringArray: value is not []byte")
var bytes []byte
switch v := value.(type) {
case nil:
*sa = StringArray{}
return nil
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return fmt.Errorf("failed to scan StringArray: unsupported value type %T", value)
}
if len(bytes) == 0 {
*sa = StringArray{}
return nil
}
return json.Unmarshal(bytes, sa)
}
+30
View File
@@ -0,0 +1,30 @@
package models
import "testing"
func TestStringArrayScanAcceptsSQLiteTextShapes(t *testing.T) {
tests := map[string]interface{}{
"bytes": []byte(`["a","b"]`),
"text": `["a","b"]`,
"empty": "",
"nil": nil,
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
var got StringArray
if err := got.Scan(input); err != nil {
t.Fatalf("scan StringArray: %v", err)
}
if input == "" || input == nil {
if len(got) != 0 {
t.Fatalf("expected empty StringArray, got %v", got)
}
return
}
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected StringArray: %v", got)
}
})
}
}
+10
View File
@@ -21,3 +21,13 @@ type LoadNotification struct {
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
}
// TrafficReportNotification 定义了流量定时报告的数据库模型
type TrafficReportNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
Monthly bool `json:"monthly" gorm:"type:boolean;default:false"` // 月报
}
+112 -1
View File
@@ -1,5 +1,18 @@
package models
import (
"fmt"
"net/url"
"path"
"strings"
)
const (
ThemeConfigurationManaged = "managed"
ThemeConfigurationRaw = "raw"
ThemeConfigurationRedirect = "redirect"
)
// Theme represents a komari theme information
type Theme struct {
Name string `json:"name"` // 主题名称
@@ -13,7 +26,7 @@ type Theme struct {
}
type Configuration struct {
Type string `json:"type"` // managed
Type string `json:"type"` // managed raw redirect
Icon string `json:"icon"` // 图标
Name any `json:"name"`
Data any `json:"data"` // 配置数据
@@ -33,3 +46,101 @@ type ThemeConfiguration struct {
Short string `json:"short" gorm:"primaryKey;unique;not null"`
Data string `json:"data" gorm:"type:longtext" default:"{}"`
}
func (t Theme) ConfigurationType() string {
typ := strings.ToLower(strings.TrimSpace(t.Configuration.Type))
if typ == "" {
return ThemeConfigurationManaged
}
return typ
}
func (t Theme) RawHTML() (string, bool) {
if t.ConfigurationType() != ThemeConfigurationRaw {
return "", false
}
return configurationDataString(t.Configuration.Data)
}
func (t Theme) RedirectTarget() (string, bool) {
if t.ConfigurationType() != ThemeConfigurationRedirect {
return "", false
}
return NormalizeThemeRedirectTarget(t.Configuration.Data)
}
func (t Theme) ValidateConfiguration() error {
switch t.ConfigurationType() {
case ThemeConfigurationManaged:
return nil
case ThemeConfigurationRaw:
html, ok := t.RawHTML()
if !ok || strings.TrimSpace(html) == "" {
return fmt.Errorf("raw 类型主题需要在 configuration.data 中提供 HTML 字符串")
}
return nil
case ThemeConfigurationRedirect:
if _, ok := t.RedirectTarget(); !ok {
return fmt.Errorf("redirect 类型主题需要在 configuration.data 中提供站内相对路径")
}
return nil
default:
return fmt.Errorf("不支持的主题类型: %s", t.Configuration.Type)
}
}
func NormalizeThemeRedirectTarget(data any) (string, bool) {
target, ok := configurationDataString(data)
if !ok {
return "", false
}
target = strings.TrimSpace(target)
if target == "" || strings.Contains(target, "\\") || strings.HasPrefix(target, "//") {
return "", false
}
parsed, err := url.Parse(target)
if err != nil || parsed.IsAbs() || parsed.Host != "" {
return "", false
}
cleanInputPath := parsed.Path
if strings.HasPrefix(cleanInputPath, "/") {
cleanInputPath = strings.TrimLeft(cleanInputPath, "/")
} else {
for strings.HasPrefix(cleanInputPath, "../") {
cleanInputPath = strings.TrimPrefix(cleanInputPath, "../")
}
}
for _, segment := range strings.Split(cleanInputPath, "/") {
if segment == ".." {
return "", false
}
}
cleanPath := cleanInputPath
if cleanPath == "" {
cleanPath = "/"
} else {
cleanPath = path.Clean(cleanPath)
if cleanPath == "." {
cleanPath = "/"
} else {
cleanPath = "/" + strings.TrimPrefix(cleanPath, "/")
}
}
normalized := url.URL{
Path: cleanPath,
RawQuery: parsed.RawQuery,
Fragment: parsed.Fragment,
}
return normalized.String(), true
}
func configurationDataString(data any) (string, bool) {
value, ok := data.(string)
return value, ok
}
+141
View File
@@ -0,0 +1,141 @@
package models
import "testing"
func TestThemeValidateConfiguration(t *testing.T) {
tests := map[string]struct {
theme Theme
wantErr bool
}{
"managed empty data": {
theme: Theme{
Configuration: Configuration{Type: ThemeConfigurationManaged},
},
},
"missing type defaults to managed": {
theme: Theme{},
},
"raw html": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRaw,
Data: "<!doctype html><title>raw</title>",
},
},
},
"raw rejects empty html": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRaw,
Data: " ",
},
},
wantErr: true,
},
"redirect relative path": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRedirect,
Data: "/dashboard?tab=nodes",
},
},
},
"redirect rejects absolute url": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRedirect,
Data: "https://example.com",
},
},
wantErr: true,
},
"redirect rejects parent traversal": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRedirect,
Data: "/../admin",
},
},
wantErr: true,
},
"unknown type": {
theme: Theme{
Configuration: Configuration{Type: "hosted"},
},
wantErr: true,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
err := tt.theme.ValidateConfiguration()
if tt.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestNormalizeThemeRedirectTarget(t *testing.T) {
tests := map[string]struct {
input string
want string
ok bool
}{
"leading slash": {
input: "/nodes",
want: "/nodes",
ok: true,
},
"missing leading slash": {
input: "nodes",
want: "/nodes",
ok: true,
},
"parent prefix points to root path": {
input: "../settings",
want: "/settings",
ok: true,
},
"multiple parent prefixes point to root path": {
input: "../../settings/site",
want: "/settings/site",
ok: true,
},
"query and fragment": {
input: "nodes?id=1#cpu",
want: "/nodes?id=1#cpu",
ok: true,
},
"absolute parent traversal": {
input: "/../admin",
},
"nested parent traversal": {
input: "nodes/../admin",
},
"protocol relative": {
input: "//example.com",
},
"backslash": {
input: `\admin`,
},
"absolute url": {
input: "https://example.com",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got, ok := NormalizeThemeRedirectTarget(tt.input)
if ok != tt.ok {
t.Fatalf("ok = %v, want %v", ok, tt.ok)
}
if got != tt.want {
t.Fatalf("target = %q, want %q", got, tt.want)
}
})
}
}
+144
View File
@@ -0,0 +1,144 @@
package notification
import (
"fmt"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm/clause"
)
func validateTrafficReportNotification(notification models.TrafficReportNotification) error {
if notification.Client == "" {
return fmt.Errorf("client UUID cannot be empty")
}
if notification.Enable && !notification.Daily && !notification.Weekly && !notification.Monthly {
return fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
}
return nil
}
func ValidateTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
for _, notification := range notifications {
if err := validateTrafficReportNotification(notification); err != nil {
return err
}
}
return nil
}
func buildEnabledTrafficReportNotifications(uuids []string, existing []models.TrafficReportNotification) ([]models.TrafficReportNotification, error) {
if len(uuids) == 0 {
return nil, fmt.Errorf("at least one client UUID is required")
}
existingByClient := make(map[string]models.TrafficReportNotification, len(existing))
for _, notification := range existing {
existingByClient[notification.Client] = notification
}
notifications := make([]models.TrafficReportNotification, 0, len(uuids))
for _, uuid := range uuids {
if uuid == "" {
return nil, fmt.Errorf("client UUID cannot be empty")
}
existingNotification, ok := existingByClient[uuid]
if !ok || (!existingNotification.Daily && !existingNotification.Weekly && !existingNotification.Monthly) {
return nil, fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
}
notifications = append(notifications, models.TrafficReportNotification{
Client: uuid,
Enable: true,
})
}
return notifications, nil
}
// ListTrafficReportNotifications 获取所有流量定时报告配置(关联客户端信息)
func ListTrafficReportNotifications() ([]models.TrafficReportNotification, error) {
db := dbcore.GetDBInstance()
var notifications []models.TrafficReportNotification
err := db.Model(&models.TrafficReportNotification{}).Preload("ClientInfo").Find(&notifications).Error
return notifications, err
}
// EditTrafficReportNotifications 批量更新流量定时报告配置
func EditTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
if err := ValidateTrafficReportNotifications(notifications); err != nil {
return err
}
db := dbcore.GetDBInstance()
return db.Model(&models.TrafficReportNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable", "daily", "weekly", "monthly"}),
}).
Select("client", "enable", "daily", "weekly", "monthly").
Create(notifications).Error
}
// EnableTrafficReportNotifications 批量启用(仅更新 enable 字段)
func EnableTrafficReportNotifications(uuids []string) error {
if len(uuids) == 0 {
return fmt.Errorf("at least one client UUID is required")
}
db := dbcore.GetDBInstance()
var existing []models.TrafficReportNotification
if err := db.Where("client IN ?", uuids).Find(&existing).Error; err != nil {
return err
}
notifications, err := buildEnabledTrafficReportNotifications(uuids, existing)
if err != nil {
return err
}
return db.Model(&models.TrafficReportNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
}
// DisableTrafficReportNotifications 批量禁用
func DisableTrafficReportNotifications(uuids []string) error {
if len(uuids) == 0 {
return fmt.Errorf("at least one client UUID is required")
}
db := dbcore.GetDBInstance()
var notifications []models.TrafficReportNotification
for _, uuid := range uuids {
if uuid == "" {
return fmt.Errorf("client UUID cannot be empty")
}
notifications = append(notifications, models.TrafficReportNotification{
Client: uuid,
Enable: false,
})
}
return db.Model(&models.TrafficReportNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
}
// GetEnabledTrafficReportByType 获取启用了指定类型报告的客户端配置
func GetEnabledTrafficReportByType(daily, weekly, monthly bool) ([]models.TrafficReportNotification, error) {
db := dbcore.GetDBInstance()
var notifications []models.TrafficReportNotification
query := db.Model(&models.TrafficReportNotification{}).Where("enable = ?", true)
if daily {
query = query.Where("daily = ?", true)
} else if weekly {
query = query.Where("weekly = ?", true)
} else if monthly {
query = query.Where("monthly = ?", true)
}
err := query.Find(&notifications).Error
return notifications, err
}
@@ -0,0 +1,36 @@
package notification
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/komari-monitor/komari/database/models"
)
func TestValidateTrafficReportNotificationsRejectsEnabledWithoutCadence(t *testing.T) {
err := ValidateTrafficReportNotifications([]models.TrafficReportNotification{{
Client: "client-a",
Enable: true,
}})
assert.Error(t, err)
}
func TestBuildEnabledTrafficReportNotificationsRequiresExistingCadence(t *testing.T) {
_, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, nil)
assert.Error(t, err)
_, err = buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{Client: "client-a"}})
assert.Error(t, err)
notifications, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{
Client: "client-a",
Daily: true,
}})
require.NoError(t, err)
require.Len(t, notifications, 1)
assert.Equal(t, "client-a", notifications[0].Client)
assert.True(t, notifications[0].Enable)
}
+49 -28
View File
@@ -172,7 +172,7 @@ func CompactRecord() error {
return err
}
if flags.DatabaseType == "sqlite" {
if flags.IsSQLite() {
db.Exec("PRAGMA wal_checkpoint(PASSIVE);")
}
//log.Printf("Record compaction completed")
@@ -180,12 +180,19 @@ func CompactRecord() error {
}
func migrateOldRecords(db *gorm.DB) error {
// 计算 4 小时前的时间
fourHoursAgo := time.Now().Add(-4 * time.Hour)
return migrateOldRecordsAt(db, time.Now())
}
func compactRecordCutoff(now time.Time) time.Time {
return now.Add(-4 * time.Hour).Truncate(15 * time.Minute)
}
func migrateOldRecordsAt(db *gorm.DB, now time.Time) error {
cutoff := compactRecordCutoff(now)
// 查询 records 表中超过 4 小时的记录
var records []models.Record
if err := db.Table("records").Where("time < ?", fourHoursAgo).Find(&records).Error; err != nil {
if err := db.Table("records").Where("time < ?", cutoff).Find(&records).Error; err != nil {
return err
}
@@ -195,24 +202,29 @@ func migrateOldRecords(db *gorm.DB) error {
// 按 Client 和 15 分钟时间段分组,并存储所有记录以计算分位数
type groupData struct {
Cpu []float32
Gpu []float32
Load []float32
Temp []float32
Ram []int64
RamTotal []int64
Swap []int64
SwapTotal []int64
Disk []int64
DiskTotal []int64
NetIn []int64
NetOut []int64
NetTotalUp []int64
NetTotalDown []int64
Process []int
Connections []int
ConnectionsUdp []int
Uptime []int64
Cpu []float32
Gpu []float32
Load []float32
Temp []float32
Ram []int64
RamTotal []int64
Swap []int64
SwapTotal []int64
Disk []int64
DiskTotal []int64
NetIn []int64
NetOut []int64
NetTotalUp []int64
NetTotalDown []int64
TrafficUp int64
TrafficDown int64
LatestTime time.Time
LatestTotalUp int64
LatestTotalDown int64
Process []int
Connections []int
ConnectionsUdp []int
Uptime []int64
}
groupedRecords := make(map[string]*groupData)
@@ -236,6 +248,13 @@ func migrateOldRecords(db *gorm.DB) error {
data.NetOut = append(data.NetOut, record.NetOut)
data.NetTotalUp = append(data.NetTotalUp, record.NetTotalUp)
data.NetTotalDown = append(data.NetTotalDown, record.NetTotalDown)
data.TrafficUp += record.TrafficUp
data.TrafficDown += record.TrafficDown
if data.LatestTime.IsZero() || record.Time.ToTime().After(data.LatestTime) {
data.LatestTime = record.Time.ToTime()
data.LatestTotalUp = record.NetTotalUp
data.LatestTotalDown = record.NetTotalDown
}
data.Process = append(data.Process, record.Process)
data.Connections = append(data.Connections, record.Connections)
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
@@ -329,8 +348,10 @@ func migrateOldRecords(db *gorm.DB) error {
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
NetIn: getIntPercentile(data.NetIn, 0.2),
NetOut: getIntPercentile(data.NetOut, 0.2),
NetTotalUp: getIntPercentile(data.NetTotalUp, high_percentile),
NetTotalDown: getIntPercentile(data.NetTotalDown, high_percentile),
NetTotalUp: data.LatestTotalUp,
NetTotalDown: data.LatestTotalDown,
TrafficUp: data.TrafficUp,
TrafficDown: data.TrafficDown,
Process: getInt32Percentile(data.Process, high_percentile),
Connections: getInt32Percentile(data.Connections, high_percentile),
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
@@ -350,7 +371,7 @@ func migrateOldRecords(db *gorm.DB) error {
}
// 删除 records 表中的旧数据
if err := tx.Table("records").Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
if err := tx.Table("records").Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
return err
}
@@ -360,11 +381,11 @@ func migrateOldRecords(db *gorm.DB) error {
// migrateGPURecords 压缩GPU记录数据
func migrateGPURecords(db *gorm.DB) error {
fourHoursAgo := time.Now().Add(-4 * time.Hour)
cutoff := compactRecordCutoff(time.Now())
// 查询超过4小时的GPU记录
var gpuRecords []models.GPURecord
if err := db.Where("time < ?", fourHoursAgo).Find(&gpuRecords).Error; err != nil {
if err := db.Where("time < ?", cutoff).Find(&gpuRecords).Error; err != nil {
return err
}
@@ -494,7 +515,7 @@ func migrateGPURecords(db *gorm.DB) error {
}
// 删除已压缩的原始GPU数据
if err := tx.Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
if err := tx.Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
return err
}
+112 -8
View File
@@ -25,8 +25,9 @@ var _ = func() bool {
// then running migrateOldRecords and verifying the aggregation and cleanup.
func TestCompactRecord(t *testing.T) {
const totalMinutes = 12*60 + 30
now := time.Now()
threshold := now.Add(-4 * time.Hour)
now := time.Now().UTC().Truncate(time.Minute)
threshold := compactRecordCutoff(now)
overlapCutoff := threshold.Add(-1 * time.Hour)
// 使用 sqlite 内存数据库并迁移表结构
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -35,7 +36,7 @@ func TestCompactRecord(t *testing.T) {
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
expectedGroups := make(map[time.Time]struct{})
expectedRemain := 0
expectedRawRemain := 0
// 插入数据
for i := 0; i < totalMinutes; i++ {
@@ -45,10 +46,11 @@ func TestCompactRecord(t *testing.T) {
assert.NoError(t, err)
if recTime.Before(threshold) {
slot := recTime.Truncate(time.Hour)
slot := recTime.Truncate(15 * time.Minute)
expectedGroups[slot] = struct{}{}
} else {
expectedRemain++
}
if !recTime.Before(overlapCutoff) {
expectedRawRemain++
}
}
@@ -75,7 +77,7 @@ func TestCompactRecord(t *testing.T) {
}
// 运行压缩(迁移)逻辑
err = migrateOldRecords(db)
err = migrateOldRecordsAt(db, now)
assert.NoError(t, err)
// 验证 long-term 表中的聚合记录数
@@ -86,7 +88,7 @@ func TestCompactRecord(t *testing.T) {
// 验证原始表中剩余记录数
var remainCount int64
assert.NoError(t, db.Table("records").Count(&remainCount).Error)
assert.Equal(t, int64(expectedRemain), remainCount+1)
assert.Equal(t, int64(expectedRawRemain), remainCount)
// 导出压缩后的数据到 CSV
var compRecs []models.Record
@@ -128,3 +130,105 @@ func TestCompactRecord(t *testing.T) {
})
}
}
func TestCompactRecordPreservesExactTrafficDelta(t *testing.T) {
currentTime := time.Now().UTC().Truncate(time.Minute)
now := currentTime.Truncate(15 * time.Minute).Add(-5*time.Hour + time.Minute)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
records := []models.Record{
{
Client: uuid,
Time: models.FromTime(now),
NetTotalUp: 100,
NetTotalDown: 200,
TrafficUp: 0,
TrafficDown: 0,
},
{
Client: uuid,
Time: models.FromTime(now.Add(5 * time.Minute)),
NetTotalUp: 150,
NetTotalDown: 260,
TrafficUp: 50,
TrafficDown: 60,
},
{
Client: uuid,
Time: models.FromTime(now.Add(10 * time.Minute)),
NetTotalUp: 10,
NetTotalDown: 30,
TrafficUp: 10,
TrafficDown: 30,
},
}
for _, rec := range records {
assert.NoError(t, db.Create(&rec).Error)
}
assert.NoError(t, migrateOldRecordsAt(db, currentTime))
var compacted []models.Record
assert.NoError(t, db.Table("records_long_term").Find(&compacted).Error)
assert.Len(t, compacted, 1)
assert.Equal(t, int64(60), compacted[0].TrafficUp)
assert.Equal(t, int64(90), compacted[0].TrafficDown)
assert.Equal(t, int64(10), compacted[0].NetTotalUp)
assert.Equal(t, int64(30), compacted[0].NetTotalDown)
assert.True(t, compacted[0].Time.ToTime().Equal(records[2].Time.ToTime().Truncate(15*time.Minute)))
}
func TestCompactRecordRetainsOneHourOverlapWindow(t *testing.T) {
now := time.Date(2026, 6, 7, 12, 7, 0, 0, time.UTC)
cutoff := compactRecordCutoff(now)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
records := []models.Record{
{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Hour - time.Minute)), TrafficUp: 5},
{Client: uuid, Time: models.FromTime(cutoff.Add(-30 * time.Minute)), TrafficUp: 7},
{Client: uuid, Time: models.FromTime(now.Add(-3 * time.Hour)), TrafficUp: 11},
}
for _, rec := range records {
assert.NoError(t, db.Create(&rec).Error)
}
assert.NoError(t, migrateOldRecordsAt(db, now))
var remainTimes []models.Record
assert.NoError(t, db.Table("records").Order("time ASC").Find(&remainTimes).Error)
assert.Len(t, remainTimes, 2)
assert.True(t, remainTimes[0].Time.ToTime().Equal(records[1].Time.ToTime()))
assert.True(t, remainTimes[1].Time.ToTime().Equal(records[2].Time.ToTime()))
}
func TestCompactRecordOnlyMigratesCompleteFifteenMinuteBuckets(t *testing.T) {
now := time.Date(2026, 6, 7, 12, 7, 0, 0, time.UTC)
cutoff := compactRecordCutoff(now)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
compactable := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Minute)), TrafficUp: 5}
partialSlot := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(time.Minute)), TrafficUp: 7}
assert.NoError(t, db.Create(&compactable).Error)
assert.NoError(t, db.Create(&partialSlot).Error)
assert.NoError(t, migrateOldRecordsAt(db, now))
var compacted []models.Record
assert.NoError(t, db.Table("records_long_term").Order("time ASC").Find(&compacted).Error)
assert.Len(t, compacted, 1)
assert.True(t, compacted[0].Time.ToTime().Equal(compactable.Time.ToTime().Truncate(15*time.Minute)))
var rawCount int64
assert.NoError(t, db.Table("records").Where("time = ?", partialSlot.Time).Count(&rawCount).Error)
assert.Equal(t, int64(1), rawCount)
}
-35
View File
@@ -200,41 +200,6 @@ func AddDefaultOnClientUUID(uuid string) error {
return nil
}
// MigrateAllClientsExpansion 启动时把旧版 default_on=true 且 clients 为空的任务展开为当前所有客户端 UUID。
// 迁移后 clients 始终是显式列表,调度路径不再依赖 default_on。
func MigrateAllClientsExpansion() error {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
return err
}
if len(tasks) == 0 {
return nil
}
var clients []models.Client
if err := db.Select("uuid").Find(&clients).Error; err != nil {
return err
}
if len(clients) == 0 {
return nil
}
allUUIDs := make(models.StringArray, 0, len(clients))
for _, c := range clients {
if c.UUID != "" {
allUUIDs = append(allUUIDs, c.UUID)
}
}
for _, task := range tasks {
if len(task.Clients) > 0 {
continue
}
if err := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("clients", allUUIDs).Error; err != nil {
return err
}
}
return nil
}
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
+3 -3
View File
@@ -8,13 +8,13 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
)
func GetPublicInfo() (map[string]interface{}, error) {
cstPtr, err := config.GetManyAs[config.Legacy]()
cstPtr, err := config.GetManyAs[config.Settings]()
if err != nil {
return nil, err
}
@@ -134,7 +134,7 @@ func GetPublicInfo() (map[string]interface{}, error) {
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
+1 -1
View File
@@ -90,7 +90,7 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/public/defaultTheme` にコピーします。
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/web/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/web/public/defaultTheme` にコピーします。
```bash
go build -o komari
```
+1 -1
View File
@@ -90,7 +90,7 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/public/defaultTheme`。
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/web/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
+1 -1
View File
@@ -90,7 +90,7 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/public/defaultTheme`。
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/web/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
+5 -57
View File
@@ -25,63 +25,11 @@ var (
db *gorm.DB
SetDb = func(gdb *gorm.DB) {
db = gdb
migrateInPlace()
}
)
func migrateInPlace() {
if db.Migrator().HasTable("configs") && db.Migrator().HasColumn(&Legacy{}, "Sitename") {
slog.Info("[>1.1.4] Moving legacy config data...")
var oldData Legacy
if err := db.Order("id desc").First(&oldData).Error; err != nil {
db.Migrator().DropTable("configs")
} else {
var newRows []ConfigItem
val := reflect.ValueOf(oldData)
typ := reflect.TypeOf(oldData)
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("json")
key := strings.Split(tag, ",")[0]
// 过滤 id 和无用字段
if key == "" || key == "-" || key == "id" {
continue
}
valInterface := val.Field(i).Interface()
jsonBytes, _ := json.Marshal(valInterface)
newRows = append(newRows, ConfigItem{
Key: key,
Value: string(jsonBytes),
})
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Migrator().DropTable("configs"); err != nil {
return err
}
if err := tx.AutoMigrate(&ConfigItem{}); err != nil {
return err
}
if len(newRows) > 0 {
return tx.Create(&newRows).Error
}
return nil
})
if err != nil {
panic("failed " + err.Error())
}
return
if err := db.AutoMigrate(&ConfigItem{}); err != nil {
panic("failed to migrate config item table: " + err.Error())
}
}
db.AutoMigrate(&ConfigItem{})
}
)
// Get 获取原始值 (反序列化为 interface{})
func Get(key string, defaul ...any) (any, error) {
@@ -601,8 +549,8 @@ func SetMany(cst map[string]any) error {
}
type ConfigEvent struct {
Old map[string]any // Old models.Config
New map[string]any // New models.Config
Old map[string]any
New map[string]any
}
func (e ConfigEvent) IsChanged(key string) bool {
+21 -67
View File
@@ -2,19 +2,22 @@ package config
import "time"
type Legacy struct {
ID uint `json:"id,omitempty"` // 1
Sitename string `json:"sitename" default:"Komari"` // 站点名称,默认 "Komari"
Description string `json:"description" default:"A simple server monitor tool."` // 站点描述
AllowCors bool `json:"allow_cors" default:"false"` // 是否允许跨域,默认 false
Theme string `json:"theme" default:"default"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" default:"false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" default:""` // API 密钥,默认空字符串
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" default:"false"`
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
type Settings struct {
ID uint `json:"id,omitempty"` // 1
Sitename string `json:"sitename" default:"Komari"` // 站点名称,默认 "Komari"
Description string `json:"description" default:"A simple server monitor tool."` // 站点描述
CorsOriginCheckEnabled bool `json:"cors_origin_check_enabled" default:"true"` // 是否启用 API CORS 跨域请求校验,默认 true
CorsAllowedOrigins string `json:"cors_allowed_origins" default:""` // API 跨域允许列表
WsOriginCheckEnabled bool `json:"ws_origin_check_enabled" default:"true"` // 是否校验 WebSocket Origin
WsAllowedOrigins string `json:"ws_allowed_origins" default:""` // WebSocket Origin 允许列表
Theme string `json:"theme" default:"default"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" default:"false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" default:""` // API 密钥,默认空字符串
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" default:"false"`
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" default:"true"`
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // empty, mmdb, ip-api, geojs
@@ -47,7 +50,10 @@ type Legacy struct {
const (
SitenameKey = "sitename"
DescriptionKey = "description"
AllowCorsKey = "allow_cors"
CorsOriginCheckEnabledKey = "cors_origin_check_enabled"
CorsAllowedOriginsKey = "cors_allowed_origins"
WsOriginCheckEnabledKey = "ws_origin_check_enabled"
WsAllowedOriginsKey = "ws_allowed_origins"
ThemeKey = "theme"
PrivateSiteKey = "private_site"
ApiKeyKey = "api_key"
@@ -77,57 +83,5 @@ const (
RecordPreserveTimeKey = "record_preserve_time"
PingRecordPreserveTimeKey = "ping_record_preserve_time"
UpdatedAtKey = "updated_at"
XtermjsSettingsKey = "xtermjs_settings"
)
func (Legacy) TableName() string {
return "configs"
}
// Decrepted
/*
func Update(cst map[string]interface{}) error {
oldConfig, _ := GetManyAs[Legacy]()
// Proceed with update
cst["updated_at"] = time.Now().Unix()
delete(cst, "created_at")
delete(cst, "CreatedAt")
// 至少有一种登录方式启用
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
newOAuthEnabled := oldConfig.OAuthEnabled
if val, exists := cst["disable_password_login"]; exists {
newDisablePasswordLogin = val.(bool)
}
if val, exists := cst["o_auth_enabled"]; exists {
newOAuthEnabled = val.(bool)
}
if newDisablePasswordLogin && !newOAuthEnabled {
return errors.New("at least one login method must be enabled (password/oauth)")
}
// 没绑定账号也不能禁用
if newDisablePasswordLogin {
usr := &models.User{}
if err := Db.Model(&models.User{}).First(usr).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve user"))
}
if usr.SSOID == "" {
return errors.New("cannot disable password login when no SSO-bound account exists")
}
}
err := Db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
return errors.Join(err, errors.New("failed to update configuration"))
}
newConfig := &models.Config{}
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
}
//publishEvent(oldConfig, *newConfig)
return nil
})
if err != nil {
return err
}
return nil
}
*/
+345
View File
@@ -0,0 +1,345 @@
package corn
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
)
// Func 是 corn 调度器执行的任务函数。
// 调度器会为每次执行传入可取消的 context,便于任务在重载或关闭时尽快退出。
type Func func(ctx context.Context)
type job struct {
cancel context.CancelFunc
}
type schedule interface {
Next(time.Time) time.Time
}
type everySchedule struct {
interval time.Duration
}
func (s everySchedule) Next(t time.Time) time.Time {
return t.Add(s.interval)
}
type cronSchedule struct {
seconds map[int]struct{}
minutes map[int]struct{}
hours map[int]struct{}
dom map[int]struct{}
months map[int]struct{}
dow map[int]struct{}
}
func (s cronSchedule) Next(t time.Time) time.Time {
next := t.Truncate(time.Second).Add(time.Second)
limit := next.Add(366 * 24 * time.Hour)
for next.Before(limit) {
if s.match(next) {
return next
}
next = next.Add(time.Second)
}
return time.Time{}
}
func (s cronSchedule) match(t time.Time) bool {
_, okSecond := s.seconds[t.Second()]
_, okMinute := s.minutes[t.Minute()]
_, okHour := s.hours[t.Hour()]
_, okDay := s.dom[t.Day()]
_, okMonth := s.months[int(t.Month())]
_, okWeek := s.dow[int(t.Weekday())]
return okSecond && okMinute && okHour && okDay && okMonth && okWeek
}
type Manager struct {
mu sync.Mutex
jobs map[string]job
}
var defaultManager = NewManager()
func NewManager() *Manager {
return &Manager{jobs: make(map[string]job)}
}
// AddFunc 按 cron 表达式注册一个任务,fn 会在独立 goroutine 中执行。
// 支持 5 字段、6 字段 cron 表达式,以及 @every 1m 这类固定间隔表达式。
func AddFunc(name string, spec string, fn func()) error {
return AddContextFunc(name, spec, false, func(context.Context) { fn() })
}
// AddContextFunc 按 cron 表达式注册一个任务,支持传递带 context 的 func。
func AddContextFunc(name string, spec string, runImmediately bool, fn Func) error {
return defaultManager.AddContextFunc(name, spec, runImmediately, fn)
}
func Every(duration time.Duration) string {
return "@every " + duration.String()
}
func Remove(name string) {
defaultManager.Remove(name)
}
func RemovePrefix(prefix string) {
defaultManager.RemovePrefix(prefix)
}
func StopAll() {
defaultManager.StopAll()
}
func (m *Manager) AddFunc(name string, spec string, fn func()) error {
return m.AddContextFunc(name, spec, false, func(context.Context) { fn() })
}
func (m *Manager) AddContextFunc(name string, spec string, runImmediately bool, fn Func) error {
if name == "" {
return fmt.Errorf("corn job name is empty")
}
s, err := Parse(spec)
if err != nil {
return fmt.Errorf("corn job %q spec is invalid: %w", name, err)
}
if fn == nil {
return fmt.Errorf("corn job %q func is nil", name)
}
ctx, cancel := context.WithCancel(context.Background())
m.replace(name, cancel)
go m.run(ctx, name, s, runImmediately, fn)
return nil
}
func (m *Manager) Remove(name string) {
m.mu.Lock()
defer m.mu.Unlock()
if old, ok := m.jobs[name]; ok {
old.cancel()
delete(m.jobs, name)
}
}
func (m *Manager) RemovePrefix(prefix string) {
m.mu.Lock()
defer m.mu.Unlock()
for name, old := range m.jobs {
if strings.HasPrefix(name, prefix) {
old.cancel()
delete(m.jobs, name)
}
}
}
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for name, old := range m.jobs {
old.cancel()
delete(m.jobs, name)
}
}
func (m *Manager) replace(name string, cancel context.CancelFunc) {
m.mu.Lock()
defer m.mu.Unlock()
if old, ok := m.jobs[name]; ok {
old.cancel()
}
m.jobs[name] = job{cancel: cancel}
}
func (m *Manager) run(ctx context.Context, name string, s schedule, runImmediately bool, fn Func) {
if runImmediately {
go safeRun(ctx, name, fn)
}
nextTick := s.Next(time.Now())
if nextTick.IsZero() {
log.Printf("corn job %s has no next run time", name)
return
}
timer := time.NewTimer(time.Until(nextTick))
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
go safeRun(ctx, name, fn)
nextTick = s.Next(nextTick)
if nextTick.IsZero() {
return
}
resetTimer(timer, time.Until(nextTick))
}
}
}
func resetTimer(timer *time.Timer, duration time.Duration) {
if duration < 0 {
duration = 0
}
timer.Reset(duration)
}
func safeRun(ctx context.Context, name string, fn Func) {
defer func() {
if r := recover(); r != nil {
log.Printf("corn job %s panic: %v", name, r)
}
}()
select {
case <-ctx.Done():
return
default:
fn(ctx)
}
}
// Parse 解析 corn 表达式。
// 支持:
// - 5 字段:minute hour day-of-month month day-of-week
// - 6 字段:second minute hour day-of-month month day-of-week
// - @every 1m / @every 30s
//
// 字段支持 *、*/n、a-b、a-b/n、逗号列表和具体数字。
func Parse(spec string) (schedule, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, fmt.Errorf("empty spec")
}
if strings.HasPrefix(spec, "@every ") {
duration, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(spec, "@every ")))
if err != nil {
return nil, err
}
if duration <= 0 {
return nil, fmt.Errorf("@every duration must be positive")
}
return everySchedule{interval: duration}, nil
}
fields := strings.Fields(spec)
if len(fields) == 5 {
fields = append([]string{"0"}, fields...)
}
if len(fields) != 6 {
return nil, fmt.Errorf("expected 5 or 6 fields, got %d", len(fields))
}
seconds, err := parseField(fields[0], 0, 59)
if err != nil {
return nil, fmt.Errorf("second: %w", err)
}
minutes, err := parseField(fields[1], 0, 59)
if err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
hours, err := parseField(fields[2], 0, 23)
if err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
dom, err := parseField(fields[3], 1, 31)
if err != nil {
return nil, fmt.Errorf("day-of-month: %w", err)
}
months, err := parseField(fields[4], 1, 12)
if err != nil {
return nil, fmt.Errorf("month: %w", err)
}
dow, err := parseField(fields[5], 0, 7)
if err != nil {
return nil, fmt.Errorf("day-of-week: %w", err)
}
if _, ok := dow[7]; ok {
dow[0] = struct{}{}
delete(dow, 7)
}
return cronSchedule{seconds: seconds, minutes: minutes, hours: hours, dom: dom, months: months, dow: dow}, nil
}
func parseField(field string, min int, max int) (map[int]struct{}, error) {
values := make(map[int]struct{})
for _, part := range strings.Split(field, ",") {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("empty part")
}
base := part
step := 1
if strings.Contains(part, "/") {
parts := strings.Split(part, "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid step %q", part)
}
base = parts[0]
parsedStep, err := strconv.Atoi(parts[1])
if err != nil || parsedStep <= 0 {
return nil, fmt.Errorf("invalid step %q", parts[1])
}
step = parsedStep
}
start, end, err := parseRange(base, min, max)
if err != nil {
return nil, err
}
for i := start; i <= end; i += step {
values[i] = struct{}{}
}
}
return values, nil
}
func parseRange(base string, min int, max int) (int, int, error) {
if base == "*" || base == "" {
return min, max, nil
}
if strings.Contains(base, "-") {
parts := strings.Split(base, "-")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid range %q", base)
}
start, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("invalid range start %q", parts[0])
}
end, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("invalid range end %q", parts[1])
}
if start > end || start < min || end > max {
return 0, 0, fmt.Errorf("range %q out of bounds %d-%d", base, min, max)
}
return start, end, nil
}
value, err := strconv.Atoi(base)
if err != nil {
return 0, 0, fmt.Errorf("invalid value %q", base)
}
if value < min || value > max {
return 0, 0, fmt.Errorf("value %d out of bounds %d-%d", value, min, max)
}
return value, value, nil
}
+30
View File
@@ -0,0 +1,30 @@
package migrations
import "time"
// ClientInfo is the legacy table shape for migrating pre-Client model data.
type ClientInfo struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Name string `json:"name" gorm:"type:varchar(100);not null"`
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"`
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time
UpdatedAt time.Time
}
+471
View File
@@ -0,0 +1,471 @@
package migrations
import (
"encoding/json"
"fmt"
"log"
"reflect"
"strings"
"time"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/pkg/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type legacyModelConfig struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"`
PrivateSite bool `json:"private_site" gorm:"default:false"`
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"`
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"`
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"`
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"`
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"`
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"`
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"`
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"`
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"`
LoginNotification bool `json:"login_notification" gorm:"default:false"`
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"`
RecordEnabled bool `json:"record_enabled" gorm:"default:true"`
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"`
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"`
CreatedAt models.LocalTime
UpdatedAt models.LocalTime
}
func (legacyModelConfig) TableName() string {
return "configs"
}
type legacyConfig struct {
ID uint `json:"id,omitempty"`
Sitename string `json:"sitename"`
Description string `json:"description"`
Theme string `json:"theme"`
PrivateSite bool `json:"private_site"`
ApiKey string `json:"api_key"`
AutoDiscoveryKey string `json:"auto_discovery_key"`
ScriptDomain string `json:"script_domain"`
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest"`
EulaAccepted bool `json:"eula_accepted"`
BaseScriptsURLKey string `json:"base_scripts_url"`
GeoIpEnabled bool `json:"geo_ip_enabled"`
GeoIpProvider string `json:"geo_ip_provider"`
NezhaCompatEnabled bool `json:"nezha_compat_enabled"`
NezhaCompatListen string `json:"nezha_compat_listen"`
OAuthEnabled bool `json:"o_auth_enabled"`
OAuthProvider string `json:"o_auth_provider"`
DisablePasswordLogin bool `json:"disable_password_login"`
CloudflareTunnelToken string `json:"cloudflare_tunnel_token"`
CustomHead string `json:"custom_head"`
CustomBody string `json:"custom_body"`
NotificationEnabled bool `json:"notification_enabled"`
NotificationMethod string `json:"notification_method"`
NotificationTemplate string `json:"notification_template"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled"`
ExpireNotificationLeadDays int `json:"expire_notification_lead_days"`
LoginNotification bool `json:"login_notification"`
TrafficLimitPercentage float64 `json:"traffic_limit_percentage"`
RecordEnabled bool `json:"record_enabled"`
RecordPreserveTime int `json:"record_preserve_time"`
PingRecordPreserveTime int `json:"ping_record_preserve_time"`
UpdatedAt time.Time `json:"updated_at"`
}
func (legacyConfig) TableName() string {
return "configs"
}
type legacyPingTask struct {
Id uint `gorm:"column:id"`
Clients string `gorm:"column:clients"`
}
type Context struct {
DB *gorm.DB
}
// Run executes one-shot startup migrations before current runtime paths are used.
func Run(ctx Context) error {
db := ctx.DB
if db == nil {
return fmt.Errorf("migration database is nil")
}
legacyConfigTable := hasLegacyConfigTable(db)
if legacyConfigTable {
if err := migrateLegacyOidcConfig(db); err != nil {
return err
}
if err := migrateLegacyMessageSenderConfig(db); err != nil {
return err
}
}
if err := migrateLegacyClientInfo(db); err != nil {
return err
}
if err := migrateLegacyLoadNotification(db); err != nil {
return err
}
if err := migrateLegacyPingAllClientsExpansion(db); err != nil {
return err
}
if legacyConfigTable {
if err := migrateLegacyConfigToItems(db); err != nil {
return err
}
}
return nil
}
func hasLegacyConfigTable(db *gorm.DB) bool {
if !db.Migrator().HasTable("configs") {
return false
}
return hasTableColumn(db, "configs", "id") ||
hasTableColumn(db, "configs", "sitename")
}
func hasTableColumn(db *gorm.DB, tableName, columnName string) bool {
columns, err := db.Migrator().ColumnTypes(tableName)
if err != nil {
return false
}
for _, column := range columns {
if strings.EqualFold(column.Name(), columnName) {
return true
}
}
return false
}
func migrateLegacyLoadNotification(db *gorm.DB) error {
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
return db.Migrator().DropTable(&models.LoadNotification{})
}
return nil
}
func migrateLegacyOidcConfig(db *gorm.DB) error {
if db.Migrator().HasTable(&models.OidcProvider{}) {
return nil
}
log.Println("[>1.0.2] Merge OidcProvider table....")
var oldData struct {
OAuthClientID string `gorm:"column:o_auth_client_id"`
OAuthClientSecret string `gorm:"column:o_auth_client_secret"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&oldData).Error; err != nil {
return fmt.Errorf("get legacy OIDC config: %w", err)
}
if err := db.AutoMigrate(&models.OidcProvider{}); err != nil {
return err
}
addition, err := json.Marshal(map[string]string{
"client_id": oldData.OAuthClientID,
"client_secret": oldData.OAuthClientSecret,
})
if err != nil {
return fmt.Errorf("marshal legacy OIDC config: %w", err)
}
if err := db.Save(&models.OidcProvider{
Name: "github",
Addition: string(addition),
}).Error; err != nil {
return err
}
if err := db.AutoMigrate(&legacyModelConfig{}); err != nil {
return err
}
return db.Model(&legacyModelConfig{}).Where("id = 1").Update("o_auth_provider", "github").Error
}
func migrateLegacyMessageSenderConfig(db *gorm.DB) error {
if db.Migrator().HasTable(&models.MessageSenderProvider{}) {
return nil
}
log.Println("[>1.0.2] Migrate MessageSender configuration....")
var oldData struct {
TelegramBotToken string `gorm:"column:telegram_bot_token"`
TelegramChatID string `gorm:"column:telegram_chat_id"`
TelegramEndpoint string `gorm:"column:telegram_endpoint"`
EmailHost string `gorm:"column:email_host"`
EmailPort int `gorm:"column:email_port"`
EmailUsername string `gorm:"column:email_username"`
EmailPassword string `gorm:"column:email_password"`
EmailSender string `gorm:"column:email_sender"`
EmailReceiver string `gorm:"column:email_receiver"`
EmailUseSSL bool `gorm:"column:email_use_ssl"`
NotificationMethod string `gorm:"column:notification_method"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&oldData).Error; err != nil {
return fmt.Errorf("get legacy message sender config: %w", err)
}
if err := db.AutoMigrate(&models.MessageSenderProvider{}); err != nil {
return err
}
if oldData.NotificationMethod == "telegram" && oldData.TelegramBotToken != "" {
telegramConfig := map[string]interface{}{
"bot_token": oldData.TelegramBotToken,
"chat_id": oldData.TelegramChatID,
"endpoint": oldData.TelegramEndpoint,
}
if telegramConfig["endpoint"] == "" {
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
}
if err := saveLegacyMessageSenderConfig(db, "telegram", telegramConfig); err != nil {
return err
}
}
if oldData.NotificationMethod == "email" && oldData.EmailHost != "" {
emailConfig := map[string]interface{}{
"host": oldData.EmailHost,
"port": oldData.EmailPort,
"username": oldData.EmailUsername,
"password": oldData.EmailPassword,
"sender": oldData.EmailSender,
"receiver": oldData.EmailReceiver,
"use_ssl": oldData.EmailUseSSL,
}
if err := saveLegacyMessageSenderConfig(db, "email", emailConfig); err != nil {
return err
}
}
for _, column := range []string{
"telegram_bot_token",
"telegram_chat_id",
"telegram_endpoint",
"email_host",
"email_port",
"email_username",
"email_password",
"email_sender",
"email_receiver",
"email_use_ssl",
} {
if hasTableColumn(db, "configs", column) {
if err := db.Migrator().DropColumn(&legacyModelConfig{}, column); err != nil {
return err
}
}
}
return nil
}
func saveLegacyMessageSenderConfig(db *gorm.DB, name string, config map[string]interface{}) error {
configJSON, err := json.Marshal(config)
if err != nil {
return fmt.Errorf("marshal legacy %s message sender config: %w", name, err)
}
return db.Save(&models.MessageSenderProvider{
Name: name,
Addition: string(configJSON),
}).Error
}
func migrateLegacyConfigToItems(db *gorm.DB) error {
log.Println("[>1.1.4] Moving legacy config data...")
var oldData legacyConfig
if err := db.Order("id desc").First(&oldData).Error; err != nil {
if err := db.Migrator().DropTable("configs"); err != nil {
return err
}
return db.AutoMigrate(&appconfig.ConfigItem{})
}
newRows, err := legacyConfigRows(oldData)
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Migrator().DropTable("configs"); err != nil {
return err
}
if err := tx.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
return err
}
if len(newRows) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&newRows).Error
})
}
func legacyConfigRows(oldData legacyConfig) ([]appconfig.ConfigItem, error) {
val := reflect.ValueOf(oldData)
typ := reflect.TypeOf(oldData)
newRows := make([]appconfig.ConfigItem, 0, val.NumField())
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("json")
key := strings.Split(tag, ",")[0]
if key == "" || key == "-" || key == "id" {
continue
}
jsonBytes, err := json.Marshal(val.Field(i).Interface())
if err != nil {
return nil, fmt.Errorf("marshal legacy config %s: %w", key, err)
}
newRows = append(newRows, appconfig.ConfigItem{
Key: key,
Value: string(jsonBytes),
})
}
return newRows, nil
}
func migrateLegacyClientInfo(db *gorm.DB) error {
if !db.Migrator().HasTable("client_infos") {
return nil
}
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
if err := db.AutoMigrate(&models.Client{}); err != nil {
return err
}
var clientInfos []ClientInfo
if err := db.Find(&clientInfos).Error; err != nil {
return fmt.Errorf("read legacy ClientInfo table: %w", err)
}
for _, info := range clientInfos {
var client models.Client
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
continue
}
client.Name = info.Name
client.CpuName = info.CpuName
client.Virtualization = info.Virtualization
client.Arch = info.Arch
client.CpuCores = info.CpuCores
client.OS = info.OS
client.GpuName = info.GpuName
client.IPv4 = info.IPv4
client.IPv6 = info.IPv6
client.Region = info.Region
client.Remark = info.Remark
client.PublicRemark = info.PublicRemark
client.MemTotal = info.MemTotal
client.SwapTotal = info.SwapTotal
client.DiskTotal = info.DiskTotal
client.Version = info.Version
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
client.ExpiredAt = models.FromTime(info.ExpiredAt)
if err := db.Save(&client).Error; err != nil {
return fmt.Errorf("update Client record %s: %w", info.UUID, err)
}
}
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
return fmt.Errorf("backup legacy ClientInfo table: %w", err)
}
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
return nil
}
func migrateLegacyPingAllClientsExpansion(db *gorm.DB) error {
if !db.Migrator().HasTable(&models.PingTask{}) || !db.Migrator().HasTable(&models.Client{}) {
return nil
}
if !hasTableColumn(db, "ping_tasks", "all_clients") {
return nil
}
if !hasTableColumn(db, "ping_tasks", "clients") {
if err := db.Exec("ALTER TABLE ping_tasks ADD COLUMN clients text").Error; err != nil {
return fmt.Errorf("add clients column for legacy ping task expansion: %w", err)
}
}
if err := db.Table("ping_tasks").
Where("clients IS NULL OR clients = '' OR clients = '[]' OR clients = 'null'").
Update("clients", models.StringArray{}).Error; err != nil {
return fmt.Errorf("normalize legacy ping task clients: %w", err)
}
var pingTasks []legacyPingTask
if err := db.Table("ping_tasks").Select("id, clients").Where("all_clients = ?", true).Scan(&pingTasks).Error; err != nil {
return fmt.Errorf("find legacy all_clients ping tasks: %w", err)
}
if len(pingTasks) == 0 {
return nil
}
var clients []models.Client
if err := db.Select("uuid").Find(&clients).Error; err != nil {
return fmt.Errorf("find clients for legacy ping task expansion: %w", err)
}
if len(clients) == 0 {
return nil
}
allUUIDs := make(models.StringArray, 0, len(clients))
for _, client := range clients {
if client.UUID != "" {
allUUIDs = append(allUUIDs, client.UUID)
}
}
if len(allUUIDs) == 0 {
return nil
}
for _, task := range pingTasks {
if !isLegacyPingClientsEmpty(task.Clients) {
continue
}
if err := db.Table("ping_tasks").Where("id = ?", task.Id).Update("clients", allUUIDs).Error; err != nil {
return fmt.Errorf("expand legacy all_clients ping task %d: %w", task.Id, err)
}
}
return nil
}
func isLegacyPingClientsEmpty(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
return true
}
var clients []string
if err := json.Unmarshal([]byte(raw), &clients); err != nil {
return false
}
return len(clients) == 0
}
+259
View File
@@ -0,0 +1,259 @@
package migrations
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/pkg/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func openTestDB(t *testing.T, name string) *gorm.DB {
t.Helper()
dsn := "file:" + strings.ReplaceAll(name, " ", "_") + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite test db: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sqlite test db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
return db
}
func TestHasLegacyConfigTable(t *testing.T) {
t.Run("config item table", func(t *testing.T) {
db := openTestDB(t, "migrations_config_item")
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
t.Fatalf("migrate config item table: %v", err)
}
if hasLegacyConfigTable(db) {
t.Fatal("config item table was detected as legacy config table")
}
})
t.Run("legacy config table", func(t *testing.T) {
db := openTestDB(t, "migrations_legacy_config")
if err := db.AutoMigrate(&legacyModelConfig{}); err != nil {
t.Fatalf("migrate legacy config table: %v", err)
}
if !hasLegacyConfigTable(db) {
t.Fatal("legacy config table was not detected")
}
})
}
func TestRunSkipsLegacyConfigMigrationForCurrentConfigItemTable(t *testing.T) {
db := openTestDB(t, "migrations_config_item_run")
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
t.Fatalf("migrate config item table: %v", err)
}
if err := db.Create(&appconfig.ConfigItem{Key: "o_auth_provider", Value: `"github"`}).Error; err != nil {
t.Fatalf("seed config item: %v", err)
}
if err := Run(Context{DB: db}); err != nil {
t.Fatalf("run migrations: %v", err)
}
if db.Migrator().HasColumn(&legacyModelConfig{}, "id") {
t.Fatal("config item table was changed into the legacy config shape")
}
if db.Migrator().HasTable(&models.OidcProvider{}) {
t.Fatal("legacy OIDC migration ran against the config item table")
}
var item appconfig.ConfigItem
if err := db.First(&item, "key = ?", "o_auth_provider").Error; err != nil {
t.Fatalf("config item was not preserved: %v", err)
}
if item.Value != `"github"` {
t.Fatalf("unexpected config value: %s", item.Value)
}
}
func TestRunPreservesVersion120RuntimeShape(t *testing.T) {
db := openTestDB(t, "migrations_v120_runtime_shape")
if err := db.AutoMigrate(
&appconfig.ConfigItem{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.Client{},
&models.PingTask{},
); err != nil {
t.Fatalf("migrate 1.2.0 runtime shape: %v", err)
}
now := models.FromTime(time.Now())
if err := db.Create(&models.Client{UUID: "client-a", Token: "token-a", CreatedAt: now, UpdatedAt: now}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&models.PingTask{
Name: "already explicit",
Clients: models.StringArray{"client-a"},
DefaultOn: true,
Type: "icmp",
Target: "example.com",
Interval: 60,
}).Error; err != nil {
t.Fatalf("seed ping task: %v", err)
}
if err := db.Create(&appconfig.ConfigItem{Key: appconfig.OAuthProviderKey, Value: `"github"`}).Error; err != nil {
t.Fatalf("seed config item: %v", err)
}
if err := db.Create(&models.OidcProvider{Name: "github", Addition: `{"client_id":"old","client_secret":"secret"}`}).Error; err != nil {
t.Fatalf("seed oidc provider: %v", err)
}
if err := db.Create(&models.MessageSenderProvider{Name: "telegram", Addition: `{"bot_token":"old-token"}`}).Error; err != nil {
t.Fatalf("seed message sender provider: %v", err)
}
if err := Run(Context{DB: db}); err != nil {
t.Fatalf("run migrations: %v", err)
}
if db.Migrator().HasColumn(&legacyModelConfig{}, "sitename") {
t.Fatal("current config item table was treated as legacy wide config")
}
var configItem appconfig.ConfigItem
if err := db.First(&configItem, "key = ?", appconfig.OAuthProviderKey).Error; err != nil {
t.Fatalf("find config item: %v", err)
}
if configItem.Value != `"github"` {
t.Fatalf("unexpected config item value: %s", configItem.Value)
}
var oidc models.OidcProvider
if err := db.First(&oidc, "name = ?", "github").Error; err != nil {
t.Fatalf("find oidc provider: %v", err)
}
if oidc.Addition != `{"client_id":"old","client_secret":"secret"}` {
t.Fatalf("oidc provider was unexpectedly changed: %s", oidc.Addition)
}
var sender models.MessageSenderProvider
if err := db.First(&sender, "name = ?", "telegram").Error; err != nil {
t.Fatalf("find message sender provider: %v", err)
}
if sender.Addition != `{"bot_token":"old-token"}` {
t.Fatalf("message sender provider was unexpectedly changed: %s", sender.Addition)
}
var task models.PingTask
if err := db.First(&task, "name = ?", "already explicit").Error; err != nil {
t.Fatalf("find ping task: %v", err)
}
if len(task.Clients) != 1 || task.Clients[0] != "client-a" {
t.Fatalf("explicit ping task clients were changed: %v", task.Clients)
}
}
func TestRunMigratesLegacyConfigTableToConfigItems(t *testing.T) {
db := openTestDB(t, "migrations_legacy_config_to_items")
if err := db.AutoMigrate(&legacyModelConfig{}); err != nil {
t.Fatalf("migrate legacy config table: %v", err)
}
legacy := legacyModelConfig{
Sitename: "Old Komari",
Description: "legacy description",
Theme: "classic",
GeoIpEnabled: true,
GeoIpProvider: "ip-api",
OAuthProvider: "github",
NotificationMethod: "none",
TrafficLimitPercentage: 66.5,
RecordEnabled: true,
RecordPreserveTime: 48,
PingRecordPreserveTime: 12,
ExpireNotificationLeadDays: 3,
}
if err := db.Create(&legacy).Error; err != nil {
t.Fatalf("seed legacy config: %v", err)
}
if err := Run(Context{DB: db}); err != nil {
t.Fatalf("run migrations: %v", err)
}
if db.Migrator().HasColumn(&legacyModelConfig{}, "sitename") {
t.Fatal("legacy config columns were not removed")
}
var sitename appconfig.ConfigItem
if err := db.First(&sitename, "key = ?", appconfig.SitenameKey).Error; err != nil {
t.Fatalf("find migrated sitename: %v", err)
}
if sitename.Value != `"Old Komari"` {
t.Fatalf("unexpected sitename value: %s", sitename.Value)
}
var corsOriginCheck appconfig.ConfigItem
if err := db.First(&corsOriginCheck, "key = ?", appconfig.CorsOriginCheckEnabledKey).Error; err == nil {
t.Fatalf("unexpected migrated cors_origin_check_enabled value: %s", corsOriginCheck.Value)
}
}
func TestRunExpandsLegacyPingAllClientsTasks(t *testing.T) {
db := openTestDB(t, "migrations_ping_all_clients")
if err := db.AutoMigrate(&models.Client{}); err != nil {
t.Fatalf("migrate clients: %v", err)
}
if err := db.Exec(`
CREATE TABLE ping_tasks (
id integer primary key autoincrement,
weight integer not null default 0,
name varchar(255) not null,
all_clients boolean not null default false,
type varchar(12) not null default 'icmp',
target varchar(255) not null,
interval integer not null default 60
)
`).Error; err != nil {
t.Fatalf("create legacy ping_tasks: %v", err)
}
now := models.FromTime(time.Now())
clients := []models.Client{
{UUID: "client-a", Token: "token-a", CreatedAt: now, UpdatedAt: now},
{UUID: "client-b", Token: "token-b", CreatedAt: now, UpdatedAt: now},
}
if err := db.Create(&clients).Error; err != nil {
t.Fatalf("seed clients: %v", err)
}
if err := db.Exec("INSERT INTO ping_tasks (name, all_clients, type, target, interval) VALUES (?, ?, ?, ?, ?)", "legacy task", true, "icmp", "example.com", 60).Error; err != nil {
t.Fatalf("seed legacy ping task: %v", err)
}
if err := Run(Context{DB: db}); err != nil {
t.Fatalf("run migrations: %v", err)
}
var task models.PingTask
if err := db.First(&task).Error; err != nil {
t.Fatalf("find migrated ping task: %v", err)
}
if len(task.Clients) != 2 {
t.Fatalf("expected two migrated clients, got %v", task.Clients)
}
got := map[string]bool{}
for _, uuid := range task.Clients {
got[uuid] = true
}
if !got["client-a"] || !got["client-b"] {
t.Fatalf("unexpected migrated clients: %v", task.Clients)
}
raw, err := json.Marshal(task.Clients)
if err != nil {
t.Fatalf("marshal migrated clients: %v", err)
}
if string(raw) != `["client-a","client-b"]` {
t.Fatalf("unexpected clients json: %s", raw)
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
+87
View File
@@ -0,0 +1,87 @@
package v1
import "time"
type Message struct {
Type string `json:"type"`
Content string `json:"content"`
Sender string `json:"sender"`
Timestamp int64 `json:"timestamp"`
}
type IPAddress struct {
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
}
type Report struct {
UUID string `json:"uuid,omitempty"`
CPU CPUReport `json:"cpu"`
Ram RamReport `json:"ram"`
Swap RamReport `json:"swap"`
Load LoadReport `json:"load"`
Disk DiskReport `json:"disk"`
Network NetworkReport `json:"network"`
Connections ConnectionsReport `json:"connections"`
GPU *GPUDetailReport `json:"gpu,omitempty"`
Uptime int64 `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
Method string `json:"method,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type CPUReport struct {
Name string `json:"name,omitempty"`
Cores int `json:"cores,omitempty"`
Arch string `json:"arch,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type GPUDetailReport struct {
Count int `json:"count"`
AverageUsage float64 `json:"average_usage"`
DetailedInfo []GPUDeviceInfo `json:"detailed_info"`
}
type GPUDeviceInfo struct {
Name string `json:"name"`
MemoryTotal int64 `json:"memory_total"`
MemoryUsed int64 `json:"memory_used"`
Utilization float64 `json:"utilization"`
Temperature int `json:"temperature"`
}
// GPUReport is the legacy v1 single-GPU shape kept for wire compatibility.
type GPUReport struct {
Name string `json:"name,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type RamReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type LoadReport struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
type DiskReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type NetworkReport struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
TotalUp int64 `json:"totalUp"`
TotalDown int64 `json:"totalDown"`
}
type ConnectionsReport struct {
TCP int `json:"tcp"`
UDP int `json:"udp"`
}
+101
View File
@@ -0,0 +1,101 @@
package v2
import v1 "github.com/komari-monitor/komari/protocol/v1"
const (
Version = "2.0"
MethodAgentReport = "agent.report"
MethodAgentBasicInfo = "agent.basicInfo"
MethodAgentPingResult = "agent.pingResult"
MethodAgentTaskResult = "agent.taskResult"
MethodAgentExec = "agent.exec"
MethodAgentPing = "agent.ping"
MethodAgentMessage = "agent.message"
MethodAgentEvent = "agent.event"
MethodAgentTerminal = "agent.terminal.request"
MethodAgentPull = "agent.pull"
)
type Request struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
ID any `json:"id,omitempty"`
}
type Response struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
type Event struct {
ID string `json:"id"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
type ReportParams struct {
Report v1.Report `json:"report"`
AckEventIDs []string `json:"ack_event_ids,omitempty"`
}
type BasicInfoParams struct {
Info map[string]interface{} `json:"info"`
}
type PingResultParams struct {
TaskID uint `json:"task_id"`
PingType string `json:"ping_type"`
Value int `json:"value"`
FinishedAt string `json:"finished_at"`
}
type PullParams struct {
Capabilities []string `json:"capabilities,omitempty"`
AckEventIDs []string `json:"ack_event_ids,omitempty"`
LastEventID string `json:"last_event_id,omitempty"`
}
type ExecParams struct {
TaskID string `json:"task_id"`
Command string `json:"command"`
}
type PingParams struct {
TaskID uint `json:"ping_task_id"`
Type string `json:"ping_type"`
Target string `json:"ping_target"`
}
type MessageParams struct {
Type string `json:"type"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
type EventParams struct {
Type string `json:"type"`
Data any `json:"data,omitempty"`
}
type TerminalRequestParams struct {
RequestID string `json:"request_id"`
}
func Success(id any, result any) Response {
return Response{JSONRPC: Version, ID: id, Result: result}
}
func Error(id any, code int, message string, data any) Response {
return Response{JSONRPC: Version, ID: id, Error: &RPCError{Code: code, Message: message, Data: data}}
}
+124
View File
@@ -0,0 +1,124 @@
package v2
import "encoding/json"
const (
MethodNetworkTestNextTrace = "networkTest.nextTrace"
MethodNetworkTestIperf3 = "networkTest.iperf3"
MethodNetworkTestMeshTrace = "networkTest.meshTrace"
MethodNetworkTestGetMeshTraceJob = "networkTest.getMeshTraceJob"
)
type IPFamily string
const (
IPFamilyAuto IPFamily = "auto"
IPFamilyIPv4 IPFamily = "ipv4"
IPFamilyIPv6 IPFamily = "ipv6"
)
type TraceProtocol string
const (
TraceProtocolICMP TraceProtocol = "icmp"
TraceProtocolTCP TraceProtocol = "tcp"
TraceProtocolUDP TraceProtocol = "udp"
)
type MeshMode string
const (
MeshModeAllToAll MeshMode = "all_to_all"
MeshModeOneToAll MeshMode = "one_to_all"
MeshModePairs MeshMode = "pairs"
)
type NodeEndpoint struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
Host string `json:"host"`
Family IPFamily `json:"ip_family"`
}
type NextTraceParams struct {
TaskID string `json:"task_id"`
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
TargetHost string `json:"target_host"`
IPFamily IPFamily `json:"ip_family"`
Protocol TraceProtocol `json:"protocol"`
MaxHops int `json:"max_hops"`
TimeoutMs int `json:"timeout_ms"`
RawOutput bool `json:"raw_output"`
}
type NextTraceResult struct {
TaskID string `json:"task_id"`
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
TargetHost string `json:"target_host"`
IPFamily IPFamily `json:"ip_family"`
Protocol TraceProtocol `json:"protocol"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
DurationMs int `json:"duration_ms"`
OK bool `json:"ok"`
Error string `json:"error"`
Summary TraceSummary `json:"summary"`
Hops []TraceHop `json:"hops"`
Raw json.RawMessage `json:"raw"`
RawText string `json:"raw_text"`
ExitCode int `json:"exit_code"`
Truncated bool `json:"truncated"`
}
type TraceSummary struct {
HopCount int `json:"hop_count"`
Reached bool `json:"reached"`
PacketLoss float64 `json:"packet_loss"`
RTTMs float64 `json:"rtt_ms"`
}
type TraceHop struct {
Hop int `json:"hop"`
Host string `json:"host"`
IP string `json:"ip"`
RTTMs float64 `json:"rtt_ms"`
Loss float64 `json:"loss"`
ASN string `json:"asn"`
Location string `json:"location"`
}
type MeshTraceParams struct {
SourceNodeIDs []string `json:"source_node_ids"`
TargetNodeIDs []string `json:"target_node_ids"`
Mode MeshMode `json:"mode"`
IPFamily IPFamily `json:"ip_family"`
Protocol TraceProtocol `json:"protocol"`
MaxHops int `json:"max_hops"`
TimeoutMs int `json:"timeout_ms"`
MaxConcurrency int `json:"max_concurrency"`
PerAgentLimit int `json:"per_agent_limit"`
}
type MeshTraceAccepted struct {
JobID string `json:"job_id"`
Status string `json:"status"`
TotalPairs int `json:"total_pairs"`
AcceptedAt string `json:"accepted_at"`
PollIntervalMs int `json:"poll_interval_ms"`
}
type MeshTraceJobSnapshot struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Total int `json:"total"`
Done int `json:"done"`
Failed int `json:"failed"`
Running int `json:"running"`
Results []NextTraceResult `json:"results"`
StartedAt string `json:"started_at"`
UpdatedAt string `json:"updated_at"`
FinishedAt string `json:"finished_at"`
Error string `json:"error"`
}
+317
View File
@@ -0,0 +1,317 @@
package v2
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestNetworkTestMethodConstants(t *testing.T) {
tests := map[string]string{
MethodNetworkTestNextTrace: "networkTest.nextTrace",
MethodNetworkTestIperf3: "networkTest.iperf3",
MethodNetworkTestMeshTrace: "networkTest.meshTrace",
MethodNetworkTestGetMeshTraceJob: "networkTest.getMeshTraceJob",
}
for got, want := range tests {
if got != want {
t.Fatalf("method constant = %q, want %q", got, want)
}
}
}
func TestNextTraceParamsJSON(t *testing.T) {
params := NextTraceParams{
TaskID: "trace-1",
SourceID: "agent-a",
TargetID: "agent-b",
TargetHost: "203.0.113.10",
IPFamily: IPFamilyIPv4,
Protocol: TraceProtocolICMP,
MaxHops: 30,
TimeoutMs: 5000,
RawOutput: true,
}
data, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, data, []string{
"task_id",
"source_id",
"target_id",
"target_host",
"ip_family",
"protocol",
"max_hops",
"timeout_ms",
"raw_output",
})
assertNoCamelCaseKeys(t, data)
var decoded NextTraceParams
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, params) {
t.Fatalf("decoded params = %#v, want %#v", decoded, params)
}
}
func TestNextTraceResultJSON(t *testing.T) {
result := NextTraceResult{
TaskID: "trace-1",
SourceID: "agent-a",
TargetID: "agent-b",
TargetHost: "203.0.113.10",
IPFamily: IPFamilyIPv4,
Protocol: TraceProtocolICMP,
StartedAt: "2026-06-05T10:00:00Z",
FinishedAt: "2026-06-05T10:00:01Z",
DurationMs: 1000,
OK: true,
Error: "",
Summary: TraceSummary{
HopCount: 2,
Reached: true,
PacketLoss: 0,
RTTMs: 12.5,
},
Hops: []TraceHop{
{Hop: 1, Host: "router", IP: "192.0.2.1", RTTMs: 1.2, Loss: 0, ASN: "AS64500", Location: "lab"},
{Hop: 2, Host: "target", IP: "203.0.113.10", RTTMs: 12.5, Loss: 0, ASN: "AS64501", Location: "edge"},
},
Raw: json.RawMessage(`{"parser":"nexttrace"}`),
RawText: "raw trace text",
ExitCode: 0,
Truncated: false,
}
data, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, data, []string{
"task_id",
"source_id",
"target_id",
"target_host",
"ip_family",
"protocol",
"started_at",
"finished_at",
"duration_ms",
"ok",
"error",
"summary",
"hops",
"raw",
"raw_text",
"exit_code",
"truncated",
})
assertJSONKeysAt(t, data, "summary", []string{"hop_count", "reached", "packet_loss", "rtt_ms"})
assertJSONKeysAt(t, data, "hops.0", []string{"hop", "host", "ip", "rtt_ms", "loss", "asn", "location"})
assertNoCamelCaseKeys(t, data)
var decoded NextTraceResult
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, result) {
t.Fatalf("decoded result = %#v, want %#v", decoded, result)
}
}
func TestMeshTraceParamsJSON(t *testing.T) {
params := MeshTraceParams{
SourceNodeIDs: []string{"agent-a"},
TargetNodeIDs: []string{"agent-b", "agent-c"},
Mode: MeshModeOneToAll,
IPFamily: IPFamilyIPv6,
Protocol: TraceProtocolTCP,
MaxHops: 20,
TimeoutMs: 8000,
MaxConcurrency: 4,
PerAgentLimit: 2,
}
data, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, data, []string{
"source_node_ids",
"target_node_ids",
"mode",
"ip_family",
"protocol",
"max_hops",
"timeout_ms",
"max_concurrency",
"per_agent_limit",
})
assertNoCamelCaseKeys(t, data)
var decoded MeshTraceParams
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, params) {
t.Fatalf("decoded params = %#v, want %#v", decoded, params)
}
}
func TestMeshTraceJobSnapshotJSON(t *testing.T) {
snapshot := MeshTraceJobSnapshot{
JobID: "mesh-1",
Status: "running",
Total: 2,
Done: 1,
Failed: 0,
Running: 1,
Results: []NextTraceResult{
{
TaskID: "trace-1",
SourceID: "agent-a",
TargetID: "agent-b",
TargetHost: "203.0.113.10",
IPFamily: IPFamilyIPv4,
Protocol: TraceProtocolUDP,
StartedAt: "2026-06-05T10:00:00Z",
FinishedAt: "2026-06-05T10:00:01Z",
DurationMs: 1000,
OK: false,
Error: "timeout",
Summary: TraceSummary{HopCount: 0, Reached: false, PacketLoss: 1, RTTMs: 0},
Hops: []TraceHop{},
Raw: json.RawMessage(`null`),
RawText: "",
ExitCode: 1,
Truncated: false,
},
},
StartedAt: "2026-06-05T10:00:00Z",
UpdatedAt: "2026-06-05T10:00:01Z",
FinishedAt: "",
Error: "",
}
data, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
assertJSONKeys(t, data, []string{
"job_id",
"status",
"total",
"done",
"failed",
"running",
"results",
"started_at",
"updated_at",
"finished_at",
"error",
})
assertJSONKeysAt(t, data, "results.0", []string{"task_id", "source_id", "target_id", "target_host"})
assertNoCamelCaseKeys(t, data)
var decoded MeshTraceJobSnapshot
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(decoded, snapshot) {
t.Fatalf("decoded snapshot = %#v, want %#v", decoded, snapshot)
}
}
func assertJSONKeys(t *testing.T, data []byte, keys []string) {
t.Helper()
var object map[string]any
if err := json.Unmarshal(data, &object); err != nil {
t.Fatal(err)
}
for _, key := range keys {
if _, ok := object[key]; !ok {
t.Fatalf("missing JSON key %q in %s", key, data)
}
}
}
func assertJSONKeysAt(t *testing.T, data []byte, path string, keys []string) {
t.Helper()
var root any
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
object := jsonObjectAt(t, root, path)
for _, key := range keys {
if _, ok := object[key]; !ok {
t.Fatalf("missing JSON key %q at %q in %s", key, path, data)
}
}
}
func assertNoCamelCaseKeys(t *testing.T, data []byte) {
t.Helper()
var value any
if err := json.Unmarshal(data, &value); err != nil {
t.Fatal(err)
}
var walk func(any)
walk = func(current any) {
switch typed := current.(type) {
case map[string]any:
for key, next := range typed {
if strings.ContainsAny(key, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
t.Fatalf("JSON key %q contains uppercase characters in %s", key, data)
}
walk(next)
}
case []any:
for _, next := range typed {
walk(next)
}
}
}
walk(value)
}
func jsonObjectAt(t *testing.T, root any, path string) map[string]any {
t.Helper()
current := root
for _, part := range strings.Split(path, ".") {
switch typed := current.(type) {
case map[string]any:
current = typed[part]
case []any:
if part != "0" {
t.Fatalf("unsupported array path segment %q", part)
}
current = typed[0]
default:
t.Fatalf("path %q reached non-container value %#v", path, current)
}
}
object, ok := current.(map[string]any)
if !ok {
t.Fatalf("path %q is not a JSON object: %#v", path, current)
}
return object
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"syscall"
"time"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/secureconfig"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"time"
"unicode"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/pkg/config"
"github.com/patrickmn/go-cache"
)
+1 -1
View File
@@ -4,9 +4,9 @@ import (
_ "github.com/komari-monitor/komari/utils/messageSender/bark"
_ "github.com/komari-monitor/komari/utils/messageSender/email"
_ "github.com/komari-monitor/komari/utils/messageSender/empty"
_ "github.com/komari-monitor/komari/utils/messageSender/javascript"
_ "github.com/komari-monitor/komari/utils/messageSender/serverchan3"
_ "github.com/komari-monitor/komari/utils/messageSender/serverchanturbo"
_ "github.com/komari-monitor/komari/utils/messageSender/javascript"
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
)
+1 -1
View File
@@ -8,10 +8,10 @@ import (
"sync"
"time"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
+9 -9
View File
@@ -1,21 +1,21 @@
package serverchan3
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// Addition 为 Server酱³ 推送通道的配置项
// 所有字段通过管理页面以 JSON 形式进行设置
type Addition struct {
// APIURL 为接口完整地址,例如:https://<uid>.push.ft07.com/send/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://<uid>.push.ft07.com/send/<sendkey>.send;参考:https://sc3.ft07.com/"`
// Tags 为可选标签,使用 | 分割,例如:tag1|tag2|tag3
Tags string `json:"tags" help:"可选标签,使用 | 分割,例如 tag1|tag2|tag3"`
// APIURL 为接口完整地址,例如:https://<uid>.push.ft07.com/send/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://<uid>.push.ft07.com/send/<sendkey>.send;参考:https://sc3.ft07.com/"`
// Tags 为可选标签,使用 | 分割,例如:tag1|tag2|tag3
Tags string `json:"tags" help:"可选标签,使用 | 分割,例如 tag1|tag2|tag3"`
}
// 注册 Server酱³ 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChan3Sender{}
})
}
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChan3Sender{}
})
}
+13 -13
View File
@@ -1,25 +1,25 @@
package serverchanturbo
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// Addition 为 Server酱 Turbo 推送通道的配置项
// 仅允许配置接口地址及可选的通道/隐藏IP/openid,固定以 JSON 发送
type Addition struct {
// APIURL 为接口完整地址,例如:https://sctapi.ftqq.com/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://sctapi.ftqq.com/<sendkey>.send;参考:https://sct.ftqq.com/"`
// Channel 为本次推送使用的消息通道,最多两个,多个用 | 隔开,例如:9|66
Channel string `json:"channel" help:"消息通道,可选,多个用 | 隔开,例如 9|66"`
// NoIP 是否隐藏调用 IP,填 1 则隐藏
NoIP string `json:"noip" help:"是否隐藏调用IP,填 1 隐藏;为空则不隐藏"`
// OpenID 消息抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔
OpenID string `json:"openid" help:"抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔"`
// APIURL 为接口完整地址,例如:https://sctapi.ftqq.com/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://sctapi.ftqq.com/<sendkey>.send;参考:https://sct.ftqq.com/"`
// Channel 为本次推送使用的消息通道,最多两个,多个用 | 隔开,例如:9|66
Channel string `json:"channel" help:"消息通道,可选,多个用 | 隔开,例如 9|66"`
// NoIP 是否隐藏调用 IP,填 1 则隐藏
NoIP string `json:"noip" help:"是否隐藏调用IP,填 1 隐藏;为空则不隐藏"`
// OpenID 消息抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔
OpenID string `json:"openid" help:"抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔"`
}
// 注册 Server酱 Turbo 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChanTurboSender{}
})
}
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChanTurboSender{}
})
}
+52 -63
View File
@@ -5,91 +5,80 @@ import (
"math"
"time"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
func CheckExpireScheduledWork() {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location()) // UTC 9AM = CST 17PM
if now.After(next) {
next = next.Add(24 * time.Hour)
}
duration := next.Sub(now)
time.Sleep(duration)
CheckExpire()
}
cfg, err := config.GetMany(map[string]any{
config.ExpireNotificationEnabledKey: false,
config.ExpireNotificationLeadDaysKey: 7,
})
if err != nil {
time.Sleep(time.Second)
continue
func CheckExpire() {
cfg, err := config.GetMany(map[string]any{
config.ExpireNotificationEnabledKey: false,
config.ExpireNotificationLeadDaysKey: 7,
})
if err != nil {
return
}
clients_all, err := clients.GetAllClientBasicInfo()
if err != nil {
return
}
checkTime := time.Now()
// 过期提醒检查(仅当启用过期通知时)
if cfg[config.ExpireNotificationEnabledKey].(bool) {
notificationLeadDays := int(cfg[config.ExpireNotificationLeadDaysKey].(float64)) // Json unmarshal 会将数字解析为 float64
type clientToExpireInfo struct {
Name string
DaysLeft int
}
clients_all, err := clients.GetAllClientBasicInfo()
if err != nil {
time.Sleep(time.Second)
continue
}
var clientLeadToExpire []clientToExpireInfo
checkTime := time.Now()
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
// 过期提醒检查(仅当启用过期通知时)
if cfg[config.ExpireNotificationEnabledKey].(bool) {
notificationLeadDays := int(cfg[config.ExpireNotificationLeadDaysKey].(float64)) // Json unmarshal 会将数字解析为 float64
type clientToExpireInfo struct {
Name string
DaysLeft int
if clientExpireTime.Before(checkTime) {
continue
}
var clientLeadToExpire []clientToExpireInfo
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
if clientExpireTime.Before(checkTime) {
continue
}
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
if len(clientLeadToExpire) > 0 {
message := ""
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("• %s (%dd)\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Expire,
Time: time.Now(),
Message: message,
Emoji: "⏳",
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
// 等待1秒,防止多次触发
time.Sleep(time.Second)
for _, client := range clients_all {
renewal.CheckAndAutoRenewal(client)
if len(clientLeadToExpire) > 0 {
message := ""
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("• %s (%dd)\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Expire,
Time: time.Now(),
Message: message,
Emoji: "⏳",
})
}
}
for _, client := range clients_all {
renewal.CheckAndAutoRenewal(client)
}
}
+14 -26
View File
@@ -1,6 +1,7 @@
package notifier
import (
"fmt"
"log"
"reflect"
"sync"
@@ -11,21 +12,18 @@ import (
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/utils/messageSender"
)
// LoadNotificationService 管理定时器和任务
type LoadNotificationService struct {
mu sync.Mutex
tickers map[int]*time.Ticker
tasks map[int][]models.LoadNotification
stopChan chan struct{}
mu sync.Mutex
tasks map[int][]models.LoadNotification
}
var LoadNotificationManager = &LoadNotificationService{
tickers: make(map[int]*time.Ticker),
tasks: make(map[int][]models.LoadNotification),
stopChan: make(chan struct{}),
tasks: make(map[int][]models.LoadNotification),
}
// Reload 重载时间表
@@ -33,11 +31,7 @@ func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotifica
m.mu.Lock()
defer m.mu.Unlock()
// 停止所有现有定时器
for _, ticker := range m.tickers {
ticker.Stop()
}
m.tickers = make(map[int]*time.Ticker)
corn.RemovePrefix("load-notification:")
m.tasks = make(map[int][]models.LoadNotification)
// 按Interval分组任务
@@ -48,22 +42,16 @@ func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotifica
// 为每个唯一的Interval创建定时器
for interval, tasks := range taskGroups {
ticker := time.NewTicker(time.Duration(interval) * time.Minute)
m.tickers[interval] = ticker
interval := interval
tasks := append([]models.LoadNotification(nil), tasks...)
m.tasks[interval] = tasks
go func(ticker *time.Ticker, tasks []models.LoadNotification) {
for {
select {
case <-ticker.C:
for _, task := range tasks {
go executeLoadNotificationTask(task)
}
case <-m.stopChan:
return
}
if err := corn.AddFunc(fmt.Sprintf("load-notification:%d", interval), corn.Every(time.Duration(interval)*time.Minute), func() {
for _, task := range tasks {
go executeLoadNotificationTask(task)
}
}(ticker, tasks)
}); err != nil {
return err
}
}
return nil
+1 -1
View File
@@ -6,11 +6,11 @@ import (
"sync"
"time"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
+4 -4
View File
@@ -7,11 +7,11 @@ import (
"strings"
"time"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/ws"
agent_runtime "github.com/komari-monitor/komari/web/agent"
cache "github.com/patrickmn/go-cache"
)
@@ -23,7 +23,7 @@ var trafficCache = cache.New(30*24*time.Hour, time.Hour) // 30天缓存,1小
// 由外部协程每分钟调用一次
func CheckTraffic() {
// 获取最新上报与客户端配置
reports := ws.GetLatestReport()
reports := agent_runtime.GetLatestReport()
if len(reports) == 0 {
return
}
@@ -92,7 +92,7 @@ func CheckTraffic() {
if curStep > lastStep { // 只在进入新步进时提醒一次
trafficCache.SetDefault(key, curStep)
msg := fmt.Sprintf("used %d%% (%s / %s), type=%s", curStep, humanBytes(used), humanBytes(c.TrafficLimit), strings.ToLower(c.TrafficLimitType))
// 发送通知(内部会检查 NotificationEnabled)
_ = messageSender.SendEvent(models.EventMessage{
+221
View File
@@ -0,0 +1,221 @@
package notifier
import (
"fmt"
"log"
"strings"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/utils/messageSender"
"gorm.io/gorm"
)
// InitTrafficReportSchedule 注册三个定时任务:日报、周报、月报
func InitTrafficReportSchedule() {
// 日报:每天凌晨 0 点
if err := corn.AddFunc("traffic-report-daily", "0 0 0 * * *", func() {
sendTrafficReport(true, false, false)
}); err != nil {
log.Println("Failed to register daily traffic report job:", err)
}
// 周报:每周一凌晨 0 点 (dow=1)
if err := corn.AddFunc("traffic-report-weekly", "0 0 0 * * 1", func() {
sendTrafficReport(false, true, false)
}); err != nil {
log.Println("Failed to register weekly traffic report job:", err)
}
// 月报:每月 1 日凌晨 0 点
if err := corn.AddFunc("traffic-report-monthly", "0 0 0 1 * *", func() {
sendTrafficReport(false, false, true)
}); err != nil {
log.Println("Failed to register monthly traffic report job:", err)
}
log.Println("Traffic report schedules registered: daily, weekly, monthly")
}
// sendTrafficReport 汇聚所有启用了指定报告类型的服务器流量,合并成一条通知发送
func sendTrafficReport(daily, weekly, monthly bool) {
// 检查全局通知开关
enabled, err := config.GetAs[bool](config.NotificationEnabledKey, false)
if err != nil || !enabled {
return
}
db := dbcore.GetDBInstance()
now := time.Now()
// 计算时间范围
var start, end time.Time
var eventType, label, suffix string
switch {
case daily:
yesterday := now.AddDate(0, 0, -1)
start = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location())
end = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 59, 59, 0, yesterday.Location())
eventType = messageevent.DReport
label = "daily"
suffix = "昨日流量"
case weekly:
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
lastMonday := now.AddDate(0, 0, -(weekday-1)-7)
lastSunday := lastMonday.AddDate(0, 0, 6)
start = time.Date(lastMonday.Year(), lastMonday.Month(), lastMonday.Day(), 0, 0, 0, 0, lastMonday.Location())
end = time.Date(lastSunday.Year(), lastSunday.Month(), lastSunday.Day(), 23, 59, 59, 0, lastSunday.Location())
eventType = messageevent.WReport
label = "weekly"
suffix = "上周流量"
case monthly:
firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
lastDayOfLastMonth := firstOfThisMonth.Add(-time.Second)
start = firstOfLastMonth
end = lastDayOfLastMonth
eventType = messageevent.MReport
label = "monthly"
suffix = "上个月流量"
default:
return
}
// 查询所有启用该类型报告的服务器配置
var notifications []models.TrafficReportNotification
query := db.Model(&models.TrafficReportNotification{}).Where("enable = ?", true)
if daily {
query = query.Where("daily = ?", true)
} else if weekly {
query = query.Where("weekly = ?", true)
} else if monthly {
query = query.Where("monthly = ?", true)
}
if err := query.Find(&notifications).Error; err != nil {
log.Printf("Failed to query traffic report notifications (%s): %v", label, err)
return
}
if len(notifications) == 0 {
return
}
// 获取客户端信息
clientUUIDs := make([]string, 0, len(notifications))
for _, n := range notifications {
clientUUIDs = append(clientUUIDs, n.Client)
}
var clientList []models.Client
if err := db.Where("uuid IN ?", clientUUIDs).Find(&clientList).Error; err != nil {
log.Printf("Failed to query clients for traffic report (%s): %v", label, err)
return
}
clientMap := make(map[string]models.Client, len(clientList))
for _, c := range clientList {
clientMap[c.UUID] = c
}
// 为每个服务器统计流量并拼接消息
var lines []string
eventClients := make([]models.Client, 0, len(notifications))
for _, n := range notifications {
c, ok := clientMap[n.Client]
if !ok {
continue
}
used, err := getClientTrafficInRange(n.Client, c.TrafficLimitType, start, end)
if err != nil {
log.Printf("Failed to compute traffic for client %s (%s): %v", n.Client, label, err)
continue
}
lines = append(lines, fmt.Sprintf("%s%s:%s", c.Name, suffix, humanBytes(used)))
eventClients = append(eventClients, c)
}
if len(lines) == 0 {
return
}
message := strings.Join(lines, "\n")
var emoji string
switch {
case daily:
emoji = "📊"
case weekly:
emoji = "📈"
case monthly:
emoji = "📅"
}
if err := messageSender.SendEvent(models.EventMessage{
Event: eventType,
Clients: eventClients,
Time: now,
Emoji: emoji,
Message: message,
}); err != nil {
log.Printf("Failed to send %s traffic report: %v", label, err)
}
}
// getClientTrafficInRange 查询某客户端在指定时间段内的流量增量
// 通过累加持久化的精确流量增量字段计算用量
func getClientTrafficInRange(clientUUID string, trafficType string, start, end time.Time) (int64, error) {
return getClientTrafficInRangeWithDB(dbcore.GetDBInstance(), clientUUID, trafficType, start, end)
}
func getClientTrafficInRangeWithDB(db *gorm.DB, clientUUID string, trafficType string, start, end time.Time) (int64, error) {
type trafficDeltaRecord struct {
Time models.LocalTime `gorm:"column:time"`
TrafficUp int64 `gorm:"column:traffic_up"`
TrafficDown int64 `gorm:"column:traffic_down"`
}
var recentRecords []trafficDeltaRecord
if err := db.Table("records").
Select("time, traffic_up, traffic_down").
Where("client = ? AND time >= ? AND time <= ?", clientUUID, start, end).
Find(&recentRecords).Error; err != nil {
return 0, err
}
var longTermRecords []trafficDeltaRecord
if err := db.Table("records_long_term").
Select("time, traffic_up, traffic_down").
Where("client = ? AND time >= ? AND time <= ?", clientUUID, start, end).
Find(&longTermRecords).Error; err != nil {
return 0, err
}
var totalUp int64
var totalDown int64
longTermSlots := make(map[time.Time]struct{}, len(longTermRecords))
for _, record := range longTermRecords {
totalUp += record.TrafficUp
totalDown += record.TrafficDown
longTermSlots[record.Time.ToTime()] = struct{}{}
}
for _, record := range recentRecords {
// records_long_term stores one aggregated row per 15-minute slot.
// Skip raw rows only when that exact slot is already present there.
slot := record.Time.ToTime().Truncate(15 * time.Minute)
if _, exists := longTermSlots[slot]; exists {
continue
}
totalUp += record.TrafficUp
totalDown += record.TrafficDown
}
return computeUsedByType(strings.ToLower(trafficType), totalUp, totalDown), nil
}
+65
View File
@@ -0,0 +1,65 @@
package notifier
import (
"testing"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestGetClientTrafficInRangeAvoidsOverlappingRawAndLongTermRows(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
clientUUID := "client-overlap"
start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
sharedSlot := start.Add(15 * time.Minute)
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
Client: clientUUID,
Time: models.FromTime(sharedSlot),
TrafficUp: 100,
TrafficDown: 200,
}).Error)
rawRecords := []models.Record{
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(1 * time.Minute)), TrafficUp: 40, TrafficDown: 80},
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(5 * time.Minute)), TrafficUp: 60, TrafficDown: 120},
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(16 * time.Minute)), TrafficUp: 30, TrafficDown: 50},
}
for _, record := range rawRecords {
assert.NoError(t, db.Create(&record).Error)
}
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, sharedSlot.Add(30*time.Minute))
assert.NoError(t, err)
assert.Equal(t, int64(380), used)
}
func TestGetClientTrafficInRangeSumsPersistedDeltasAcrossCounterReset(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
clientUUID := "client-reset"
start := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
records := []models.Record{
{Client: clientUUID, Time: models.FromTime(start.Add(0 * time.Minute)), NetTotalUp: 100, NetTotalDown: 200, TrafficUp: 0, TrafficDown: 0},
{Client: clientUUID, Time: models.FromTime(start.Add(5 * time.Minute)), NetTotalUp: 150, NetTotalDown: 260, TrafficUp: 50, TrafficDown: 60},
{Client: clientUUID, Time: models.FromTime(start.Add(10 * time.Minute)), NetTotalUp: 10, NetTotalDown: 30, TrafficUp: 10, TrafficDown: 30},
{Client: clientUUID, Time: models.FromTime(start.Add(15 * time.Minute)), NetTotalUp: 25, NetTotalDown: 40, TrafficUp: 15, TrafficDown: 10},
}
for _, record := range records {
assert.NoError(t, db.Create(&record).Error)
}
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, start.Add(20*time.Minute))
assert.NoError(t, err)
assert.Equal(t, int64(175), used)
}
-13
View File
@@ -1,13 +0,0 @@
package oauth
import (
_ "github.com/komari-monitor/komari/utils/oauth/cloudflare"
_ "github.com/komari-monitor/komari/utils/oauth/factory"
_ "github.com/komari-monitor/komari/utils/oauth/generic"
_ "github.com/komari-monitor/komari/utils/oauth/github"
_ "github.com/komari-monitor/komari/utils/oauth/qq"
)
func All() {
//empty function to ensure all OIDC providers are registered
}
+20 -46
View File
@@ -2,18 +2,20 @@ package utils
import (
"context"
"fmt"
"sync"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/pkg/corn"
v2 "github.com/komari-monitor/komari/protocol/v2"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
// PingTaskManager 管理定时器和任务
type PingTaskManager struct {
mu sync.Mutex
cancelFunc context.CancelFunc
tasks map[int][]models.PingTask
mu sync.Mutex
tasks map[int][]models.PingTask
}
var manager = &PingTaskManager{
@@ -25,12 +27,7 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.cancelFunc != nil {
m.cancelFunc()
}
ctx, cancel := context.WithCancel(context.Background())
m.cancelFunc = cancel
corn.RemovePrefix("ping:")
m.tasks = make(map[int][]models.PingTask)
// 按Interval分组任务
@@ -44,40 +41,22 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
// 为每个唯一的Interval创建协程
for interval, tasks := range taskGroups {
interval := interval
tasks := append([]models.PingTask(nil), tasks...)
m.tasks[interval] = tasks
go m.runPreciseLoop(ctx, time.Duration(interval)*time.Second, tasks)
if err := corn.AddContextFunc(fmt.Sprintf("ping:%d", interval), corn.Every(time.Duration(interval)*time.Second), false, func(ctx context.Context) {
for _, task := range tasks {
go executePingTask(ctx, task)
}
}); err != nil {
return err
}
}
return nil
}
func (m *PingTaskManager) runPreciseLoop(ctx context.Context, interval time.Duration, tasks []models.PingTask) {
// Start the first timer.
timer := time.NewTimer(interval)
// This will be the reference point for all future ticks.
// By adding the interval to this time, we avoid accumulating execution delays.
nextTick := time.Now().Add(interval)
defer timer.Stop()
for {
select {
case <-timer.C:
onlineClients := ws.GetConnectedClients()
for _, task := range tasks {
go executePingTask(ctx, task, onlineClients)
}
nextTick = nextTick.Add(interval)
timer.Reset(time.Until(nextTick))
case <-ctx.Done():
return
}
}
}
// executePingTask 执行单个PingTask
func executePingTask(ctx context.Context, task models.PingTask, onlineClients map[string]*ws.SafeConn) {
func executePingTask(ctx context.Context, task models.PingTask) {
var message struct {
TaskID uint `json:"ping_task_id"`
Message string `json:"message"`
@@ -90,7 +69,7 @@ func executePingTask(ctx context.Context, task models.PingTask, onlineClients ma
message.Type = task.Type
message.Target = task.Target
for _, clientUUID := range targetPingClientUUIDs(task, onlineClients) {
for _, clientUUID := range targetPingClientUUIDs(task) {
select {
case <-ctx.Done():
// Context was canceled, stop sending pings.
@@ -99,17 +78,12 @@ func executePingTask(ctx context.Context, task models.PingTask, onlineClients ma
// Context is still active, continue.
}
if conn, exists := onlineClients[clientUUID]; exists && conn != nil {
if err := conn.WriteJSON(message); err != nil {
continue
}
}
agent_runtime.DispatchPing(clientUUID, message, v2.PingParams{TaskID: task.Id, Type: task.Type, Target: task.Target})
}
}
// targetPingClientUUIDs 根据任务配置计算本次调度需要下发的在线服务器列表。
func targetPingClientUUIDs(task models.PingTask, onlineClients map[string]*ws.SafeConn) []string {
_ = onlineClients
func targetPingClientUUIDs(task models.PingTask) []string {
return task.Clients
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/ws"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
func CheckAndAutoRenewal(client models.Client) {
@@ -24,7 +24,7 @@ func CheckAndAutoRenewal(client models.Client) {
return
}
// 不在线则不续费
if _, ok := ws.GetConnectedClients()[client.UUID]; !ok {
if _, ok := agent_runtime.GetConnectedClients()[client.UUID]; !ok {
return
}
+20 -20
View File
@@ -5,14 +5,14 @@ import (
"strings"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/protocol/v1"
"github.com/komari-monitor/komari/database/models"
)
// AverageReport 根据 topPercentage 参数计算报告的平均值。
// 如果 topPercentage 为 0,则计算所有记录的平均值。
// 如果 topPercentage 大于 0 且小于等于 1,则计算从大到小排序后的前 topPercentage 记录的平均值。
func AverageReport(uuid string, time time.Time, records []common.Report, topPercentage float64) models.Record {
func AverageReport(uuid string, time time.Time, records []v1.Report, topPercentage float64) models.Record {
count := len(records)
if count == 0 {
return models.Record{}
@@ -27,7 +27,7 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
}
// 定义一个辅助函数,用于排序和求和
sumAndSort := func(getFloat32Value func(common.Report) float32, getInt64Value func(common.Report) int64, isFloat bool) (float32, int64) {
sumAndSort := func(getFloat32Value func(v1.Report) float32, getInt64Value func(v1.Report) int64, isFloat bool) (float32, int64) {
if isFloat {
sort.Slice(records, func(i, j int) bool {
return getFloat32Value(records[i]) > getFloat32Value(records[j])
@@ -54,31 +54,31 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
var sumPROCESS, sumConnections, sumConnectionsUDP int
if topPercentage > 0 && topPercentage <= 1 {
sumCPU, _ = sumAndSort(func(r common.Report) float32 { return float32(r.CPU.Usage) }, nil, true)
sumLOAD, _ = sumAndSort(func(r common.Report) float32 { return float32(r.Load.Load1) }, nil, true)
sumGPU, _ = sumAndSort(func(r common.Report) float32 {
sumCPU, _ = sumAndSort(func(r v1.Report) float32 { return float32(r.CPU.Usage) }, nil, true)
sumLOAD, _ = sumAndSort(func(r v1.Report) float32 { return float32(r.Load.Load1) }, nil, true)
sumGPU, _ = sumAndSort(func(r v1.Report) float32 {
if r.GPU != nil {
return float32(r.GPU.AverageUsage)
}
return 0
}, nil, true)
_, sumRAM = sumAndSort(nil, func(r common.Report) int64 { return r.Ram.Used }, false)
//_, sumRAMTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Ram.Total }, false)
_, sumSWAP = sumAndSort(nil, func(r common.Report) int64 { return r.Swap.Used }, false)
//_, sumSWAPTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Swap.Total }, false)
_, sumDISK = sumAndSort(nil, func(r common.Report) int64 { return r.Disk.Used }, false)
//_, sumDISKTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Disk.Total }, false)
_, sumNETIn = sumAndSort(nil, func(r common.Report) int64 { return r.Network.Down }, false)
_, sumNETOut = sumAndSort(nil, func(r common.Report) int64 { return r.Network.Up }, false)
_, sumNETTotalUp = sumAndSort(nil, func(r common.Report) int64 { return r.Network.TotalUp }, false)
_, sumNETTotalDown = sumAndSort(nil, func(r common.Report) int64 { return r.Network.TotalDown }, false)
_, sumRAM = sumAndSort(nil, func(r v1.Report) int64 { return r.Ram.Used }, false)
//_, sumRAMTotal = sumAndSort(nil, nil, func(r v1.Report) int64 { return r.Ram.Total }, false)
_, sumSWAP = sumAndSort(nil, func(r v1.Report) int64 { return r.Swap.Used }, false)
//_, sumSWAPTotal = sumAndSort(nil, nil, func(r v1.Report) int64 { return r.Swap.Total }, false)
_, sumDISK = sumAndSort(nil, func(r v1.Report) int64 { return r.Disk.Used }, false)
//_, sumDISKTotal = sumAndSort(nil, nil, func(r v1.Report) int64 { return r.Disk.Total }, false)
_, sumNETIn = sumAndSort(nil, func(r v1.Report) int64 { return r.Network.Down }, false)
_, sumNETOut = sumAndSort(nil, func(r v1.Report) int64 { return r.Network.Up }, false)
_, sumNETTotalUp = sumAndSort(nil, func(r v1.Report) int64 { return r.Network.TotalUp }, false)
_, sumNETTotalDown = sumAndSort(nil, func(r v1.Report) int64 { return r.Network.TotalDown }, false)
_, sumInt := sumAndSort(nil, func(r common.Report) int64 { return int64(r.Process) }, false)
_, sumInt := sumAndSort(nil, func(r v1.Report) int64 { return int64(r.Process) }, false)
sumPROCESS = int(sumInt)
_, sumInt = sumAndSort(nil, func(r common.Report) int64 { return int64(r.Connections.TCP) }, false)
_, sumInt = sumAndSort(nil, func(r v1.Report) int64 { return int64(r.Connections.TCP) }, false)
sumConnections = int(sumInt)
_, sumInt = sumAndSort(nil, func(r common.Report) int64 { return int64(r.Connections.UDP) }, false)
_, sumInt = sumAndSort(nil, func(r v1.Report) int64 { return int64(r.Connections.UDP) }, false)
sumConnectionsUDP = int(sumInt)
} else { // 计算所有记录的平均值
@@ -131,7 +131,7 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
// AverageGPUReports 使用与 AverageReport 相同的聚合逻辑处理GPU数据
// 返回每个GPU设备的聚合记录
func AverageGPUReports(uuid string, time time.Time, reports []common.Report, topPercentage float64) []models.GPURecord {
func AverageGPUReports(uuid string, time time.Time, reports []v1.Report, topPercentage float64) []models.GPURecord {
if len(reports) == 0 {
return []models.GPURecord{}
}
@@ -1,17 +1,17 @@
package ws
package agent
import (
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
v1 "github.com/komari-monitor/komari/protocol/v1"
"github.com/komari-monitor/komari/web/connection"
)
var (
connectedClients = make(map[string]*SafeConn)
ConnectedUsers = []*websocket.Conn{}
latestReport = make(map[string]*common.Report)
connectedClients = make(map[string]*connection.SafeConn)
connectedClientV2 = make(map[string]bool)
latestReport = make(map[string]*v1.Report)
// presenceOnly stores online state for non-WebSocket agents (e.g., Nezha gRPC)
// value keeps connectionID and a soft expiration to avoid flicker
presenceOnly = make(map[string]struct {
@@ -21,28 +21,42 @@ var (
mu = sync.RWMutex{}
)
func GetConnectedClients() map[string]*SafeConn {
func GetConnectedClients() map[string]*connection.SafeConn {
mu.RLock()
defer mu.RUnlock()
clientsCopy := make(map[string]*SafeConn)
clientsCopy := make(map[string]*connection.SafeConn)
for k, v := range connectedClients {
clientsCopy[k] = v
}
return clientsCopy
}
func SetConnectedClients(uuid string, conn *SafeConn) {
func SetConnectedClients(uuid string, conn *connection.SafeConn) {
mu.Lock()
defer mu.Unlock()
connectedClients[uuid] = conn
}
func DeleteClientConditionally(uuid string, connToRemove *SafeConn) {
func SetClientProtocolVersion(uuid string, version int) {
mu.Lock()
defer mu.Unlock()
connectedClientV2[uuid] = version >= 2
}
func IsV2Client(uuid string) bool {
mu.RLock()
defer mu.RUnlock()
return connectedClientV2[uuid]
}
func DeleteClientConditionally(uuid string, connToRemove *connection.SafeConn) {
mu.Lock()
defer mu.Unlock()
// 检查当前 map 里的 conn 是否就是要删除的这一个
if currentConn, exists := connectedClients[uuid]; exists && currentConn == connToRemove {
delete(connectedClients, uuid)
delete(connectedClientV2, uuid)
}
}
func DeleteConnectedClients(uuid string) {
@@ -50,6 +64,7 @@ func DeleteConnectedClients(uuid string) {
defer mu.Unlock()
// 只从 map 中删除,不再负责关闭连接
delete(connectedClients, uuid)
delete(connectedClientV2, uuid)
}
// SetPresence sets or clears presence for non-WebSocket agents.
@@ -102,16 +117,16 @@ func GetAllOnlineUUIDs() []string {
}
return res
}
func GetLatestReport() map[string]*common.Report {
func GetLatestReport() map[string]*v1.Report {
mu.RLock()
defer mu.RUnlock()
reportCopy := make(map[string]*common.Report)
reportCopy := make(map[string]*v1.Report)
for k, v := range latestReport {
reportCopy[k] = v
}
return reportCopy
}
func SetLatestReport(uuid string, report *common.Report) {
func SetLatestReport(uuid string, report *v1.Report) {
mu.Lock()
defer mu.Unlock()
latestReport[uuid] = report
+236
View File
@@ -0,0 +1,236 @@
package agent
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"time"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
const (
v2EventQueueLimit = 128
v2EventTTL = 5 * time.Minute
v2PingEventTTL = 3 * time.Second
)
type v2EventQueue struct {
events []v2.Event
signal chan struct{}
}
var (
v2EventMu sync.Mutex
v2EventQueues = make(map[string]*v2EventQueue)
)
func getV2EventQueueLocked(uuid string) *v2EventQueue {
q := v2EventQueues[uuid]
if q == nil {
q = &v2EventQueue{signal: make(chan struct{})}
v2EventQueues[uuid] = q
}
return q
}
func DispatchV2Event(uuid, method string, params any) bool {
if conn := GetConnectedClients()[uuid]; conn != nil {
payload := v2.Request{JSONRPC: v2.Version, Method: method, Params: params}
if conn.WriteJSON(payload) == nil {
return true
}
}
if !IsV2Client(uuid) {
return false
}
EnqueueV2Event(uuid, method, params)
return true
}
func DispatchPing(uuid string, legacy any, params v2.PingParams) bool {
if conn := GetConnectedClients()[uuid]; conn != nil {
payload := legacy
if IsV2Client(uuid) {
payload = v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentPing, Params: params}
}
if conn.WriteJSON(payload) == nil {
return true
}
}
if !IsV2Client(uuid) {
return false
}
EnqueueV2Event(uuid, v2.MethodAgentPing, params)
return true
}
func IsAgentOnline(uuid string) bool {
if GetConnectedClients()[uuid] != nil {
return true
}
return IsV2Client(uuid)
}
func EnqueueV2Event(uuid, method string, params any) v2.Event {
now := time.Now()
ttl := v2EventTTL
if method == v2.MethodAgentPing {
ttl = v2PingEventTTL
}
event := v2.Event{
ID: newV2EventID(),
Method: method,
Params: params,
CreatedAt: now.Format(time.RFC3339Nano),
ExpiresAt: now.Add(ttl).Format(time.RFC3339Nano),
}
v2EventMu.Lock()
q := getV2EventQueueLocked(uuid)
pruneExpiredV2EventsLocked(q)
coalesceV2EventLocked(q, event)
q.events = append(q.events, event)
if len(q.events) > v2EventQueueLimit {
q.events = q.events[len(q.events)-v2EventQueueLimit:]
}
close(q.signal)
q.signal = make(chan struct{})
v2EventMu.Unlock()
return event
}
func newV2EventID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err == nil {
return hex.EncodeToString(b[:])
}
return fmt.Sprintf("%d", time.Now().UnixNano())
}
func coalesceV2EventLocked(q *v2EventQueue, event v2.Event) {
key := v2EventCoalesceKey(event)
if key == "" {
return
}
filtered := q.events[:0]
for _, existing := range q.events {
if v2EventCoalesceKey(existing) != key {
filtered = append(filtered, existing)
}
}
q.events = filtered
}
func v2EventCoalesceKey(event v2.Event) string {
if event.Method != v2.MethodAgentPing {
return ""
}
var params v2.PingParams
if err := bindV2EventParams(event.Params, &params); err != nil || params.TaskID == 0 {
return ""
}
return fmt.Sprintf("%s:%d", event.Method, params.TaskID)
}
func bindV2EventParams(raw any, target any) error {
b, err := json.Marshal(raw)
if err != nil {
return err
}
return json.Unmarshal(b, target)
}
func ackV2EventsLocked(q *v2EventQueue, ackIDs []string) {
if len(ackIDs) == 0 || len(q.events) == 0 {
return
}
acked := make(map[string]struct{}, len(ackIDs))
for _, id := range ackIDs {
acked[id] = struct{}{}
}
filtered := q.events[:0]
for _, event := range q.events {
if _, ok := acked[event.ID]; !ok {
filtered = append(filtered, event)
}
}
q.events = filtered
}
func pruneExpiredV2EventsLocked(q *v2EventQueue) {
if len(q.events) == 0 {
return
}
now := time.Now()
filtered := q.events[:0]
for _, event := range q.events {
if event.ExpiresAt == "" {
filtered = append(filtered, event)
continue
}
expiresAt, err := time.Parse(time.RFC3339Nano, event.ExpiresAt)
if err != nil || expiresAt.After(now) {
filtered = append(filtered, event)
}
}
q.events = filtered
}
func TakeV2Events(uuid string, ackIDs []string, limit int) []v2.Event {
v2EventMu.Lock()
defer v2EventMu.Unlock()
q := getV2EventQueueLocked(uuid)
ackV2EventsLocked(q, ackIDs)
pruneExpiredV2EventsLocked(q)
return takeV2EventsLocked(q, limit)
}
func AckV2Events(uuid string, ackIDs []string) {
if len(ackIDs) == 0 {
return
}
v2EventMu.Lock()
defer v2EventMu.Unlock()
q := v2EventQueues[uuid]
if q == nil {
return
}
ackV2EventsLocked(q, ackIDs)
}
func takeV2EventsLocked(q *v2EventQueue, limit int) []v2.Event {
if limit <= 0 || limit > len(q.events) {
limit = len(q.events)
}
events := make([]v2.Event, limit)
copy(events, q.events[:limit])
return events
}
func WaitV2Events(uuid string, ackIDs []string, timeout time.Duration) []v2.Event {
v2EventMu.Lock()
q := getV2EventQueueLocked(uuid)
ackV2EventsLocked(q, ackIDs)
pruneExpiredV2EventsLocked(q)
events := takeV2EventsLocked(q, v2EventQueueLimit)
if len(events) > 0 || timeout <= 0 {
v2EventMu.Unlock()
return events
}
signal := q.signal
v2EventMu.Unlock()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-signal:
case <-timer.C:
}
return TakeV2Events(uuid, nil, v2EventQueueLimit)
}
+1 -1
View File
@@ -10,9 +10,9 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/pkg/config"
"gorm.io/gorm"
)
+111
View File
@@ -0,0 +1,111 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
)
func RequireSensitive2FA() gin.HandlerFunc {
return func(c *gin.Context) {
if err := VerifySensitive2FA(c); err != nil {
RespondError(c, http.StatusUnauthorized, err.Error())
c.Abort()
return
}
c.Next()
}
}
func VerifySensitive2FA(c *gin.Context) error {
if _, ok := c.Get("api_key"); ok {
return nil
}
uuid, ok := c.Get("uuid")
if !ok {
return err2FARequired()
}
uuidString, ok := uuid.(string)
if !ok || uuidString == "" {
return err2FARequired()
}
user, err := accounts.GetUserByUUID(uuidString)
if err != nil {
return err
}
if user.TwoFactor == "" {
return nil
}
code := get2FACode(c)
if code == "" {
return err2FARequired()
}
valid, err := accounts.Verify2Fa(uuidString, code)
if err != nil {
return err
}
if !valid {
return err2FAInvalid()
}
return nil
}
func get2FACode(c *gin.Context) string {
if code, ok := c.Get("2fa_code"); ok {
if codeString, ok := code.(string); ok && codeString != "" {
return codeString
}
}
if code := c.GetHeader("X-2FA-Code"); code != "" {
return code
}
if code := c.GetHeader("X-Two-Factor-Code"); code != "" {
return code
}
for _, key := range []string{"2fa_code", "two_factor_code", "otp"} {
if code := c.Query(key); code != "" {
return code
}
}
if c.Request.Body == nil || c.Request.Method == http.MethodGet {
return ""
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return ""
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if len(bodyBytes) == 0 {
return ""
}
var body map[string]interface{}
if err := json.Unmarshal(bodyBytes, &body); err != nil {
return ""
}
for _, key := range []string{"2fa_code", "two_factor_code", "otp"} {
if value, ok := body[key].(string); ok && value != "" {
return value
}
}
return ""
}
func err2FARequired() error {
return &sensitive2FAError{"2FA code is required"}
}
func err2FAInvalid() error {
return &sensitive2FAError{"Invalid 2FA code"}
}
type sensitive2FAError struct {
message string
}
func (e *sensitive2FAError) Error() string {
return e.message
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Response struct {
Status string `json:"status"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Respond sends a standardized JSON response.
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
}
// RespondSuccess sends a success response with data.
func RespondSuccess(c *gin.Context, data interface{}) {
Respond(c, http.StatusOK, "success", "", data)
}
// RespondSuccessMessage sends a success response with message and data.
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
Respond(c, http.StatusOK, "success", message, data)
}
// RespondError sends an error response with message.
func RespondError(c *gin.Context, httpStatus int, message string) {
Respond(c, httpStatus, "error", message, nil)
}
+61
View File
@@ -0,0 +1,61 @@
package api
import (
"fmt"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/web/security"
)
type WebSocketUpgradeOption func(*websocket.Upgrader)
func IsWebSocketUpgrade(c *gin.Context) bool {
return websocket.IsWebSocketUpgrade(c.Request)
}
func EnableWebSocketCompression(upgrader *websocket.Upgrader) {
upgrader.EnableCompression = true
}
func UpgradeWebSocket(c *gin.Context, options ...WebSocketUpgradeOption) (*websocket.Conn, error) {
if !IsWebSocketUpgrade(c) {
return nil, fmt.Errorf("require websocket upgrade")
}
upgrader := websocket.Upgrader{
CheckOrigin: CheckWebSocketOrigin,
}
for _, option := range options {
option(&upgrader)
}
return upgrader.Upgrade(c.Writer, c.Request, nil)
}
func CheckWebSocketOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if strings.EqualFold(os.Getenv("KOMARI_WS_DISABLE_ORIGIN"), "true") {
return true
}
if security.IsAPIKeyRequest(r) {
return true
}
if origin == "" && r.URL.Query().Get("token") != "" {
return true
}
enabled, _ := config.GetAs[bool](config.WsOriginCheckEnabledKey, true)
if !enabled {
return true
}
if origin == "" {
return false
}
if security.OriginMatchesHost(origin, r.Host) {
return true
}
allowlist, _ := config.GetAs[string](config.WsAllowedOriginsKey, "")
return security.OriginInAllowlist(origin, allowlist)
}
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"image/png"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/web/api"
"github.com/pquerna/otp/totp"
)
@@ -7,7 +7,7 @@ import (
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/ws"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
func AddClient(c *gin.Context) {
@@ -68,8 +68,8 @@ func RemoveClient(c *gin.Context) {
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
c.JSON(200, gin.H{"status": "success"})
ws.DeleteConnectedClients(uuid)
ws.DeleteLatestReport(uuid)
agent_runtime.DeleteConnectedClients(uuid)
agent_runtime.DeleteLatestReport(uuid)
}
func ClearRecord(c *gin.Context) {
@@ -2,10 +2,10 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/web/api"
)
func OrderWeight(c *gin.Context) {
@@ -5,10 +5,10 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/web/api"
)
// GetClipboard retrieves a clipboard entry by ID
@@ -5,11 +5,11 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/web/api"
)
const cloudflaredStopConfirmText = "STOP CLOUDFLARED"
@@ -11,9 +11,9 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/web/api"
)
// copyFile 复制单个文件到目标路径(会确保父目录存在)
@@ -123,7 +123,7 @@ func DownloadBackup(c *gin.Context) {
destDB := filepath.Join(tempDir, "komari.db")
dbFilePath := flags.DatabaseFile
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
if flags.IsSQLite() {
if err := backupSQLiteTo(destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
return
+29 -15
View File
@@ -2,16 +2,18 @@ package admin
import (
"encoding/json"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
agent_runtime "github.com/komari-monitor/komari/web/agent"
"github.com/komari-monitor/komari/web/api"
)
// 接受数据类型:
@@ -23,12 +25,17 @@ func Exec(c *gin.Context) {
Clients []string `json:"clients" binding:"required"`
}
var onlineClients []string
var queuedClients []string
var offlineClients []string
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
// for uuid := range ws.GetConnectedClients() {
if strings.TrimSpace(req.Command) == "" {
api.RespondError(c, 400, "Command cannot be empty")
return
}
// for uuid := range agent_runtime.GetConnectedClients() {
// if contain(req.Clients, uuid) {
// onlineClients = append(onlineClients, uuid)
// }
@@ -38,33 +45,36 @@ func Exec(c *gin.Context) {
// // }
// }
for _, uuid := range req.Clients {
if client := ws.GetConnectedClients()[uuid]; client != nil {
if client := agent_runtime.GetConnectedClients()[uuid]; client != nil {
onlineClients = append(onlineClients, uuid)
} else if agent_runtime.IsAgentOnline(uuid) {
queuedClients = append(queuedClients, uuid)
} else {
offlineClients = append(offlineClients, uuid)
}
}
if len(onlineClients) == 0 {
if len(onlineClients) == 0 && len(queuedClients) == 0 {
api.RespondError(c, 400, "No clients connected")
return
}
taskId := utils.GenerateRandomString(16)
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
taskClients := append(append([]string{}, onlineClients...), queuedClients...)
taskClients = append(taskClients, offlineClients...)
if err := tasks.CreateTask(taskId, taskClients, req.Command); err != nil {
api.RespondError(c, 500, "Failed to create task: "+err.Error())
return
}
for _, uuid := range onlineClients {
var send struct {
legacy := struct {
Message string `json:"message"`
Command string `json:"command"`
TaskId string `json:"task_id"`
}{Message: "exec", Command: req.Command, TaskId: taskId}
payload, _ := json.Marshal(legacy)
if agent_runtime.IsV2Client(uuid) {
payload, _ = json.Marshal(v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentExec, Params: v2.ExecParams{TaskID: taskId, Command: req.Command}})
}
send.Message = "exec"
send.Command = req.Command
send.TaskId = taskId
payload, _ := json.Marshal(send)
client := ws.GetConnectedClients()[uuid]
client := agent_runtime.GetConnectedClients()[uuid]
if client != nil {
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
api.RespondError(c, 400, "Client connection is broke: "+uuid)
@@ -75,11 +85,15 @@ func Exec(c *gin.Context) {
return
}
}
for _, uuid := range queuedClients {
agent_runtime.DispatchV2Event(uuid, v2.MethodAgentExec, v2.ExecParams{TaskID: taskId, Command: req.Command})
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
api.RespondSuccess(c, gin.H{
"task_id": taskId,
"clients": onlineClients,
"task_id": taskId,
"clients": onlineClients,
"queued_clients": queuedClients,
})
if len(offlineClients) > 0 {
for _, uuid := range offlineClients {
@@ -4,9 +4,9 @@ import (
"strconv"
"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"
"github.com/komari-monitor/komari/web/api"
)
func GetLogs(c *gin.Context) {
@@ -2,12 +2,12 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/factory"
"github.com/komari-monitor/komari/web/api"
)
func GetMessageSenderProvider(c *gin.Context) {
@@ -4,9 +4,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/web/api"
)
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
@@ -2,9 +2,9 @@ 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"
"github.com/komari-monitor/komari/web/api"
"gorm.io/gorm/clause"
)
@@ -0,0 +1,71 @@
package notification
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/web/api"
)
// ListTrafficReportNotifications GET /api/admin/notification/traffic-report
func ListTrafficReportNotifications(c *gin.Context) {
notifications, err := notification.ListTrafficReportNotifications()
if err != nil {
api.RespondError(c, 500, "Failed to list traffic report notifications: "+err.Error())
return
}
api.RespondSuccess(c, notifications)
}
// EditTrafficReportNotifications POST /api/admin/notification/traffic-report/edit
// Body: []TrafficReportNotification
func EditTrafficReportNotifications(c *gin.Context) {
var notifications []models.TrafficReportNotification
if err := c.ShouldBindJSON(&notifications); 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
}
if err := notification.ValidateTrafficReportNotifications(notifications); err != nil {
api.RespondError(c, 400, err.Error())
return
}
if err := notification.EditTrafficReportNotifications(notifications); err != nil {
api.RespondError(c, 500, "Failed to edit traffic report notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
// EnableTrafficReportNotifications POST /api/admin/notification/traffic-report/enable
// Body: []string (uuid list)
func EnableTrafficReportNotifications(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if err := notification.EnableTrafficReportNotifications(uuids); err != nil {
api.RespondError(c, 500, "Failed to enable traffic report notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
// DisableTrafficReportNotifications POST /api/admin/notification/traffic-report/disable
// Body: []string (uuid list)
func DisableTrafficReportNotifications(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if err := notification.DisableTrafficReportNotifications(uuids); err != nil {
api.RespondError(c, 500, "Failed to disable traffic report notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
@@ -2,13 +2,13 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/oauth"
"github.com/komari-monitor/komari/web/oauth/factory"
)
func BindingExternalAccount(c *gin.Context) {
+1 -1
View File
@@ -5,9 +5,9 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/web/api"
)
// AddPingTask 处理新增延迟监测任务请求。
@@ -1,9 +1,9 @@
package admin
import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/web/api"
"github.com/gin-gonic/gin"
)
@@ -1,11 +1,11 @@
package admin
import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/web/api"
"github.com/gin-gonic/gin"
)
+270
View File
@@ -0,0 +1,270 @@
package admin
import (
"errors"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/web/api"
)
const (
defaultXtermJSFontFamily = "'Cascadia Mono', 'Noto Sans SC', monospace"
defaultXtermJSFontSize = 16
defaultXtermJSScrollback = 5000
defaultXtermJSPadding = 16
)
// XtermJSSettings stores the admin-configurable xtermjs settings payload.
type XtermJSSettings struct {
TerminalOptions *TerminalOptions `json:"terminalOptions"`
TerminalPadding *int `json:"terminalPadding"`
TransparentBackground bool `json:"transparentBackground"`
CustomCss string `json:"customCss"`
}
// TerminalOptions stores the xterm.js terminal options.
type TerminalOptions struct {
CursorBlink *bool `json:"cursorBlink"`
ConvertEol *bool `json:"convertEol"`
FontFamily string `json:"fontFamily"`
FontSize int `json:"fontSize"`
MacOptionIsMeta *bool `json:"macOptionIsMeta"`
Scrollback *int `json:"scrollback"`
Theme *ThemeConfig `json:"theme"`
}
// ThemeConfig contains only the xterm.js theme whitelist from the API contract.
type ThemeConfig struct {
Foreground string `json:"foreground,omitempty"`
Background string `json:"background,omitempty"`
Cursor string `json:"cursor,omitempty"`
CursorAccent string `json:"cursorAccent,omitempty"`
SelectionForeground string `json:"selectionForeground,omitempty"`
SelectionBackground string `json:"selectionBackground,omitempty"`
SelectionInactiveBackground string `json:"selectionInactiveBackground,omitempty"`
Black string `json:"black,omitempty"`
Red string `json:"red,omitempty"`
Green string `json:"green,omitempty"`
Yellow string `json:"yellow,omitempty"`
Blue string `json:"blue,omitempty"`
Magenta string `json:"magenta,omitempty"`
Cyan string `json:"cyan,omitempty"`
White string `json:"white,omitempty"`
BrightBlack string `json:"brightBlack,omitempty"`
BrightRed string `json:"brightRed,omitempty"`
BrightGreen string `json:"brightGreen,omitempty"`
BrightYellow string `json:"brightYellow,omitempty"`
BrightBlue string `json:"brightBlue,omitempty"`
BrightMagenta string `json:"brightMagenta,omitempty"`
BrightCyan string `json:"brightCyan,omitempty"`
BrightWhite string `json:"brightWhite,omitempty"`
ExtendedAnsi []string `json:"extendedAnsi,omitempty"`
}
func defaultXtermJSSettings() XtermJSSettings {
return XtermJSSettings{
TerminalOptions: &TerminalOptions{
CursorBlink: boolPtr(true),
ConvertEol: boolPtr(true),
FontFamily: defaultXtermJSFontFamily,
FontSize: defaultXtermJSFontSize,
MacOptionIsMeta: boolPtr(true),
Scrollback: intPtr(defaultXtermJSScrollback),
Theme: nil,
},
TerminalPadding: intPtr(defaultXtermJSPadding),
TransparentBackground: false,
CustomCss: "",
}
}
func normalizeXtermJSSettings(input XtermJSSettings) (XtermJSSettings, error) {
normalized := defaultXtermJSSettings()
if input.TerminalOptions != nil {
options := *input.TerminalOptions
options.CursorBlink = boolOrDefault(input.TerminalOptions.CursorBlink, true)
options.ConvertEol = boolOrDefault(input.TerminalOptions.ConvertEol, true)
options.MacOptionIsMeta = boolOrDefault(input.TerminalOptions.MacOptionIsMeta, true)
if fontFamily := strings.TrimSpace(input.TerminalOptions.FontFamily); fontFamily != "" {
options.FontFamily = fontFamily
} else {
options.FontFamily = defaultXtermJSFontFamily
}
if input.TerminalOptions.FontSize >= 1 {
options.FontSize = input.TerminalOptions.FontSize
} else {
options.FontSize = defaultXtermJSFontSize
}
if input.TerminalOptions.Scrollback != nil && *input.TerminalOptions.Scrollback >= 0 {
options.Scrollback = intPtr(*input.TerminalOptions.Scrollback)
} else {
options.Scrollback = intPtr(defaultXtermJSScrollback)
}
theme, err := normalizeThemeConfig(input.TerminalOptions.Theme)
if err != nil {
return XtermJSSettings{}, err
}
options.Theme = theme
normalized.TerminalOptions = &options
}
if input.TerminalPadding != nil && *input.TerminalPadding >= 0 {
normalized.TerminalPadding = intPtr(*input.TerminalPadding)
}
normalized.TransparentBackground = input.TransparentBackground
normalized.CustomCss = input.CustomCss
return normalized, nil
}
func normalizeThemeConfig(input *ThemeConfig) (*ThemeConfig, error) {
if input == nil {
return nil, nil
}
normalized := &ThemeConfig{
Foreground: cleanThemeColor(input.Foreground),
Background: cleanThemeColor(input.Background),
Cursor: cleanThemeColor(input.Cursor),
CursorAccent: cleanThemeColor(input.CursorAccent),
SelectionForeground: cleanThemeColor(input.SelectionForeground),
SelectionBackground: cleanThemeColor(input.SelectionBackground),
SelectionInactiveBackground: cleanThemeColor(input.SelectionInactiveBackground),
Black: cleanThemeColor(input.Black),
Red: cleanThemeColor(input.Red),
Green: cleanThemeColor(input.Green),
Yellow: cleanThemeColor(input.Yellow),
Blue: cleanThemeColor(input.Blue),
Magenta: cleanThemeColor(input.Magenta),
Cyan: cleanThemeColor(input.Cyan),
White: cleanThemeColor(input.White),
BrightBlack: cleanThemeColor(input.BrightBlack),
BrightRed: cleanThemeColor(input.BrightRed),
BrightGreen: cleanThemeColor(input.BrightGreen),
BrightYellow: cleanThemeColor(input.BrightYellow),
BrightBlue: cleanThemeColor(input.BrightBlue),
BrightMagenta: cleanThemeColor(input.BrightMagenta),
BrightCyan: cleanThemeColor(input.BrightCyan),
BrightWhite: cleanThemeColor(input.BrightWhite),
}
if len(input.ExtendedAnsi) > 0 {
normalized.ExtendedAnsi = make([]string, 0, len(input.ExtendedAnsi))
for _, entry := range input.ExtendedAnsi {
trimmed := strings.TrimSpace(entry)
if trimmed == "" {
return nil, errors.New("extendedAnsi must not contain empty strings")
}
normalized.ExtendedAnsi = append(normalized.ExtendedAnsi, trimmed)
}
}
if themeIsEmpty(normalized) {
return nil, nil
}
return normalized, nil
}
func boolPtr(value bool) *bool {
return &value
}
func intPtr(value int) *int {
return &value
}
func boolOrDefault(value *bool, defaultValue bool) *bool {
if value == nil {
return boolPtr(defaultValue)
}
return boolPtr(*value)
}
func cleanThemeColor(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
return trimmed
}
func themeIsEmpty(theme *ThemeConfig) bool {
if theme == nil {
return true
}
return theme.Foreground == "" &&
theme.Background == "" &&
theme.Cursor == "" &&
theme.CursorAccent == "" &&
theme.SelectionForeground == "" &&
theme.SelectionBackground == "" &&
theme.SelectionInactiveBackground == "" &&
theme.Black == "" &&
theme.Red == "" &&
theme.Green == "" &&
theme.Yellow == "" &&
theme.Blue == "" &&
theme.Magenta == "" &&
theme.Cyan == "" &&
theme.White == "" &&
theme.BrightBlack == "" &&
theme.BrightRed == "" &&
theme.BrightGreen == "" &&
theme.BrightYellow == "" &&
theme.BrightBlue == "" &&
theme.BrightMagenta == "" &&
theme.BrightCyan == "" &&
theme.BrightWhite == "" &&
len(theme.ExtendedAnsi) == 0
}
func GetXtermJSSettings(c *gin.Context) {
defaultSettings := defaultXtermJSSettings()
settings, err := config.GetAs[XtermJSSettings](config.XtermjsSettingsKey, defaultSettings)
if err != nil {
_ = config.Set(config.XtermjsSettingsKey, defaultSettings)
settings = defaultSettings
}
normalized, err := normalizeXtermJSSettings(settings)
if err != nil {
api.RespondError(c, 500, "Failed to get xtermjs settings: "+err.Error())
return
}
api.RespondSuccess(c, normalized)
}
func SetXtermJSSettings(c *gin.Context) {
var settings XtermJSSettings
if err := c.ShouldBindJSON(&settings); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
normalized, err := normalizeXtermJSSettings(settings)
if err != nil {
api.RespondError(c, 400, "Invalid xtermjs settings: "+err.Error())
return
}
if err := config.Set(config.XtermjsSettingsKey, normalized); err != nil {
api.RespondError(c, 500, "Failed to save settings: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "update xtermjs settings", "info")
api.RespondSuccessMessage(c, "settings saved", nil)
}
+375
View File
@@ -0,0 +1,375 @@
// Do not use t.Parallel(): config.SetDb mutates package-global state.
package admin
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var xtermJSAuditDBOnce sync.Once
type xtermJSAPIResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func ensureXtermJSAuditDB() {
xtermJSAuditDBOnce.Do(func() {
flags.DatabaseType = "sqlite"
flags.DatabaseFile = "file:xtermjs_audit?mode=memory&cache=shared"
_ = dbcore.GetDBInstance()
})
}
func setupXtermJSTestDB(t *testing.T) (*gorm.DB, *sql.DB) {
t.Helper()
gin.SetMode(gin.TestMode)
testDBName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
testDSN := fmt.Sprintf("file:%s?mode=memory&cache=shared", testDBName)
testDB, err := gorm.Open(sqlite.Open(testDSN), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := testDB.DB()
require.NoError(t, err)
// Initialize the shared audit DB first, then restore the per-test config DB.
ensureXtermJSAuditDB()
config.SetDb(testDB)
t.Cleanup(func() {
_ = sqlDB.Close()
})
return testDB, sqlDB
}
func newXtermJSRouter() *gin.Engine {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("uuid", "test-uuid")
c.Next()
})
router.GET("/xtermjs", GetXtermJSSettings)
router.POST("/xtermjs", SetXtermJSSettings)
return router
}
func performXtermJSRequest(t *testing.T, router *gin.Engine, method, target string, body []byte) *httptest.ResponseRecorder {
t.Helper()
req, err := http.NewRequest(method, target, bytes.NewReader(body))
require.NoError(t, err)
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decodeXtermJSResponse(t *testing.T, w *httptest.ResponseRecorder) xtermJSAPIResponse {
t.Helper()
var resp xtermJSAPIResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
return resp
}
func decodeXtermJSSettings(t *testing.T, raw json.RawMessage) XtermJSSettings {
t.Helper()
var settings XtermJSSettings
require.NoError(t, json.Unmarshal(raw, &settings))
return settings
}
func ptr[T any](v T) *T {
return &v
}
func TestGetXtermJSSettingsReturnsDefaultWhenMissing(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
w := performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)
require.Equal(t, http.StatusOK, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "success", resp.Status)
assert.Empty(t, resp.Message)
got := decodeXtermJSSettings(t, resp.Data)
expected := defaultXtermJSSettings()
assert.Equal(t, expected, got)
var stored config.ConfigItem
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
var persisted XtermJSSettings
require.NoError(t, json.Unmarshal([]byte(stored.Value), &persisted))
assert.Equal(t, expected, persisted)
}
func TestXtermJSSettingsRoundTripAndThemeVariants(t *testing.T) {
t.Run("full config round trip", func(t *testing.T) {
setupXtermJSTestDB(t)
router := newXtermJSRouter()
input := XtermJSSettings{
TerminalOptions: &TerminalOptions{
CursorBlink: ptr(false),
ConvertEol: ptr(false),
FontFamily: "JetBrains Mono",
FontSize: 18,
MacOptionIsMeta: ptr(false),
Scrollback: ptr(9000),
Theme: &ThemeConfig{
Foreground: "#f8f8f2",
Background: "#282a36",
Cursor: "#f8f8f2",
SelectionBackground: "#44475a",
Blue: "#6272a4",
ExtendedAnsi: []string{"#111111", "#222222"},
},
},
TerminalPadding: ptr(20),
TransparentBackground: true,
CustomCss: "body { color: red; }",
}
body, err := json.Marshal(input)
require.NoError(t, err)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "success", resp.Status)
assert.Equal(t, "settings saved", resp.Message)
getResp := decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil))
got := decodeXtermJSSettings(t, getResp.Data)
assert.Equal(t, input, got)
})
t.Run("theme null stays null", func(t *testing.T) {
setupXtermJSTestDB(t)
router := newXtermJSRouter()
body := []byte(`{"terminalOptions":{"cursorBlink":true,"convertEol":true,"fontFamily":"JetBrains Mono","fontSize":17,"macOptionIsMeta":true,"scrollback":1000,"theme":null},"terminalPadding":16,"transparentBackground":false,"customCss":"."}`)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)).Data)
require.NotNil(t, got.TerminalOptions)
assert.Nil(t, got.TerminalOptions.Theme)
})
t.Run("terminalOptions null uses defaults", func(t *testing.T) {
setupXtermJSTestDB(t)
router := newXtermJSRouter()
body := []byte(`{"terminalOptions":null,"terminalPadding":24,"transparentBackground":true,"customCss":"body { background: black; }"}`)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)).Data)
expected := defaultXtermJSSettings()
expected.TerminalPadding = ptr(24)
expected.TransparentBackground = true
expected.CustomCss = "body { background: black; }"
assert.Equal(t, expected, got)
})
}
func TestXtermJSSettingsUnknownThemeFieldsAreDropped(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
body := []byte(`{"terminalOptions":{"fontFamily":"JetBrains Mono","fontSize":16,"theme":{"foreground":"#ffffff","unknownField":"keep-out","extendedAnsi":["#111111"]}}}`)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "success", resp.Status)
getResp := decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil))
got := decodeXtermJSSettings(t, getResp.Data)
require.NotNil(t, got.TerminalOptions)
require.NotNil(t, got.TerminalOptions.Theme)
assert.Equal(t, "#ffffff", got.TerminalOptions.Theme.Foreground)
var stored config.ConfigItem
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
assert.NotContains(t, stored.Value, "unknownField")
}
func TestXtermJSSettingsValidationAndFallbacks(t *testing.T) {
t.Run("extendedAnsi and body validation", func(t *testing.T) {
cases := []struct {
name string
body []byte
}{
{
name: "empty string",
body: []byte(`{"terminalOptions":{"theme":{"extendedAnsi":[""]}}}`),
},
{
name: "space string",
body: []byte(`{"terminalOptions":{"theme":{"extendedAnsi":[" "]}}}`),
},
{
name: "illegal type",
body: []byte(`{"terminalOptions":{"theme":{"extendedAnsi":[1]}}}`),
},
{
name: "invalid json",
body: []byte(`{"terminalOptions":`),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
setupXtermJSTestDB(t)
router := newXtermJSRouter()
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", tc.body)
require.Equal(t, http.StatusBadRequest, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "error", resp.Status)
assert.NotEmpty(t, resp.Message)
})
}
})
t.Run("fallbacks are normalized on save and read", func(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
neg := -1
body, err := json.Marshal(XtermJSSettings{
TerminalOptions: &TerminalOptions{
CursorBlink: ptr(true),
ConvertEol: ptr(true),
FontFamily: " ",
FontSize: 0,
MacOptionIsMeta: ptr(true),
Scrollback: &neg,
Theme: &ThemeConfig{
Foreground: " ",
},
},
TerminalPadding: &neg,
TransparentBackground: true,
CustomCss: "body { color: blue; }",
})
require.NoError(t, err)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)).Data)
expected := defaultXtermJSSettings()
expected.TransparentBackground = true
expected.CustomCss = "body { color: blue; }"
assert.Equal(t, expected, got)
var stored config.ConfigItem
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
var persisted XtermJSSettings
require.NoError(t, json.Unmarshal([]byte(stored.Value), &persisted))
assert.Equal(t, expected, persisted)
})
t.Run("corrupt JSON is repaired on get", func(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
raw := `{"terminalOptions":`
require.NoError(t, testDB.Create(&config.ConfigItem{
Key: config.XtermjsSettingsKey,
Value: raw,
}).Error)
w := performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, w).Data)
expected := defaultXtermJSSettings()
assert.Equal(t, expected, got)
var stored config.ConfigItem
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
var persisted XtermJSSettings
require.NoError(t, json.Unmarshal([]byte(stored.Value), &persisted))
assert.Equal(t, expected, persisted)
})
}
func TestXtermJSSettingsNormalizesSeededLegacyConfig(t *testing.T) {
testDB, _ := setupXtermJSTestDB(t)
router := newXtermJSRouter()
seeded := `{"terminalOptions":{"cursorBlink":true,"convertEol":true,"fontFamily":"JetBrains Mono","fontSize":0,"macOptionIsMeta":true,"scrollback":1200,"theme":null},"terminalPadding":12,"transparentBackground":true,"customCss":"seeded"}`
require.NoError(t, testDB.Create(&config.ConfigItem{
Key: config.XtermjsSettingsKey,
Value: seeded,
}).Error)
w := performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)
require.Equal(t, http.StatusOK, w.Code)
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, w).Data)
expected := XtermJSSettings{
TerminalOptions: &TerminalOptions{
CursorBlink: ptr(true),
ConvertEol: ptr(true),
FontFamily: "JetBrains Mono",
FontSize: 16,
MacOptionIsMeta: ptr(true),
Scrollback: ptr(1200),
Theme: nil,
},
TerminalPadding: ptr(12),
TransparentBackground: true,
CustomCss: "seeded",
}
assert.Equal(t, expected, got)
}
func TestXtermJSSettingsSaveFailureReturns500(t *testing.T) {
_, sqlDB := setupXtermJSTestDB(t)
router := newXtermJSRouter()
require.NoError(t, sqlDB.Close())
body := []byte(`{"terminalOptions":{"fontFamily":"JetBrains Mono","fontSize":16,"theme":null}}`)
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
require.Equal(t, http.StatusInternalServerError, w.Code)
resp := decodeXtermJSResponse(t, w)
assert.Equal(t, "error", resp.Status)
assert.Contains(t, resp.Message, "Failed to save settings:")
}
+1 -1
View File
@@ -2,8 +2,8 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/web/api"
)
func GetTasks(c *gin.Context) {
@@ -4,11 +4,11 @@ import (
"net"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/web/api"
)
func TestSendMessage(c *gin.Context) {
+11 -3
View File
@@ -14,11 +14,11 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/public"
)
// UploadTheme 上传主题
@@ -202,6 +202,10 @@ func extractAndValidateTheme(zipPath string) (models.Theme, error) {
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
}
if err := themeInfo.ValidateConfiguration(); err != nil {
return themeInfo, err
}
// 创建主题目录
themeDir := filepath.Join("./data/theme", themeInfo.Short)
@@ -671,6 +675,10 @@ func peekThemeFromZip(zipPath string) (models.Theme, error) {
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
}
if err := themeInfo.ValidateConfiguration(); err != nil {
return themeInfo, err
}
return themeInfo, nil
}
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more