Compare commits

..
4 Commits
Author SHA1 Message Date
Akizon77 f0cc0fba38 fix: pin 1.5.1 frontend release source 2026-09-24 23:21:05 +08:00
Akizon77 1d972c42dc fix: pin 1.5.1-rc1 frontend version 2026-09-24 17:25:06 +08:00
Akizon f089b08b3a 裁剪1下 2026-09-24 17:21:44 +08:00
Akizon 6e70263c50 重定向旧版本的主题接口? 2026-09-24 17:21:37 +08:00
86 changed files with 2782 additions and 2018 deletions

No files matched your search

+3 -1
View File
@@ -24,7 +24,9 @@ jobs:
TAG: ${{ github.event.release.tag_name }}
run: |
frontend_ref=""
if [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
if [[ "$TAG" == "1.5.1" || "$TAG" == "1.5.1-rc1" ]]; then
frontend_ref="1.5.0-fix1"
elif [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
frontend_ref="${BASH_REMATCH[1]}"
fi
echo "frontend_ref=$frontend_ref" >> "$GITHUB_OUTPUT"
+1 -2
View File
@@ -15,7 +15,6 @@ komari.db
/utils/geoip/data
.vscode/
/backup
/cache
# Test binary, built with `go test -c`
*.test
@@ -38,4 +37,4 @@ komari
/dist/
.codex
/web/filemanager/data
/web/filemanager/data
-1
View File
@@ -12,7 +12,6 @@ COPY --chmod=755 komari-${TARGETOS}-${TARGETARCH} /app/komari
ENV GIN_MODE=release
ENV KOMARI_LISTEN=0.0.0.0:25774
ENV GODEBUG=disablethp=1
EXPOSE 25774
-5
View File
@@ -6,7 +6,6 @@ import (
appserver "github.com/komari-monitor/komari/internal/server"
logger "github.com/komari-monitor/komari/utils/log"
frontendpublic "github.com/komari-monitor/komari/web/public"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/spf13/cobra"
@@ -33,10 +32,6 @@ func init() {
// 具体各阶段的职责与顺序见 internal/server.App。这里只负责串联:
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
func RunServer() {
if err := frontendpublic.PrepareDefaultDist(); err != nil {
logger.Fatalf("server", "prepare default frontend failed: %v", err)
}
app := appserver.New(appserver.Options{ListenAddr: flags.Listen})
if err := app.Bootstrap(); err != nil {
_ = app.Shutdown()
+64
View File
@@ -0,0 +1,64 @@
package clipboard
import (
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
// CreateClipboard 创建剪贴板记录
func CreateClipboard(cb *models.Clipboard) error {
db := dbcore.GetDBInstance()
cb.CreatedAt = time.Now().UTC()
cb.UpdatedAt = cb.CreatedAt
return db.Create(cb).Error
}
// GetClipboardByID 根据ID获取剪贴板记录
func GetClipboardByID(id int) (*models.Clipboard, error) {
var cb models.Clipboard
db := dbcore.GetDBInstance()
if err := db.First(&cb, id).Error; err != nil {
return nil, err
}
return &cb, nil
}
// UpdateClipboardFields 更新剪贴板记录
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
db := dbcore.GetDBInstance()
delete(fields, "created_at")
fields["updated_at"] = time.Now().UTC()
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteClipboard 删除剪贴板记录
func DeleteClipboard(id int) error {
db := dbcore.GetDBInstance()
// Check if record exists first
var cb models.Clipboard
if err := db.First(&cb, id).Error; err != nil {
return err // Record not found or other error
}
return db.Delete(&cb).Error
}
// DeleteClipboardBatch 批量删除剪贴板记录
func DeleteClipboardBatch(ids []int) error {
if len(ids) == 0 {
return nil
}
db := dbcore.GetDBInstance()
return db.Where("id IN ?", ids).Delete(&models.Clipboard{}).Error
}
// ListClipboard 列出所有剪贴板记录
func ListClipboard() ([]models.Clipboard, error) {
var list []models.Clipboard
db := dbcore.GetDBInstance()
if err := db.Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
+6 -15
View File
@@ -186,13 +186,6 @@ const (
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
var versionID string
// nil means a fresh install; an empty value means an unmarked legacy install.
var lastRunVersion *string
func LastRunVersion() *string {
return lastRunVersion
}
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
// 之前采集。
@@ -228,18 +221,14 @@ func resolveDatabaseFile() string {
//
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
func backupOnVersionUpgrade() {
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
prevVersion = strings.TrimSpace(prevVersion)
versionRecorded := readErr == nil && prevVersion != ""
lastRunVersion = nil
if versionRecorded || dbFileExistedAtStartup {
lastRunVersion = &prevVersion
}
if versionID == "" {
return
}
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
prevVersion = strings.TrimSpace(prevVersion)
versionRecorded := readErr == nil && prevVersion != ""
// 版本未变化,无需备份。
if versionRecorded && prevVersion == versionID {
return
@@ -461,6 +450,8 @@ func doInitialize() error {
&models.User{},
&models.Client{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.PingTask{},
&models.OidcProvider{},
+13
View File
@@ -0,0 +1,13 @@
package models
import "time"
type Clipboard struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
Text string `json:"text" gorm:"type:longtext"`
Name string `json:"name" gorm:"type:varchar(255)"`
Weight int `json:"weight" gorm:"type:int"`
Remark string `json:"remark" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+12
View File
@@ -12,6 +12,18 @@ type OfflineNotification struct {
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
}
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
type LoadNotification struct {
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"type:varchar(255)"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
LastNotified *time.Time `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"`
+63
View File
@@ -0,0 +1,63 @@
package notification
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/notifier"
"gorm.io/gorm"
)
func AddLoadNotification(clients []string, name string, metric string, threshold float32, ratio float32, interval int) (uint, error) {
db := dbcore.GetDBInstance()
notification := models.LoadNotification{
Clients: clients,
Name: name,
Metric: metric,
Threshold: threshold,
Ratio: ratio,
Interval: interval,
}
if err := db.Create(&notification).Error; err != nil {
return 0, err
}
return notification.Id, ReloadLoadNotificationSchedule()
}
func DeleteLoadNotification(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("id IN ?", id).Delete(&models.LoadNotification{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return ReloadLoadNotificationSchedule()
}
func EditLoadNotification(notifications []*models.LoadNotification) error {
db := dbcore.GetDBInstance()
for _, notification := range notifications {
result := db.Model(&models.LoadNotification{}).Where("id = ?", notification.Id).Updates(notification)
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
}
return ReloadLoadNotificationSchedule()
}
func GetAllLoadNotifications() ([]models.LoadNotification, error) {
db := dbcore.GetDBInstance()
var notifications []models.LoadNotification
if err := db.Find(&notifications).Error; err != nil {
return nil, err
}
return notifications, nil
}
func ReloadLoadNotificationSchedule() error {
db := dbcore.GetDBInstance()
var loadNotifications []models.LoadNotification
if err := db.Find(&loadNotifications).Error; err != nil {
return err
}
return notifier.ReloadLoadNotificationSchedule(loadNotifications)
}
+1
View File
@@ -98,6 +98,7 @@ func GetPublicInfo() (map[string]interface{}, error) {
"record_preserve_time": retention.MaxDays * 24,
"ping_record_preserve_time": retention.MaxDays * 24,
"private_site": cst.PrivateSite,
"visitor_audit_enabled": cst.VisitorAuditEnabled,
"theme": cst.Theme,
"theme_settings": tc_data,
}, nil
-1
View File
@@ -1196,7 +1196,6 @@ After=network.target
[Service]
Type=simple
Environment="GODEBUG=disablethp=1"
ExecStart=${BINARY_PATH} server -l 0.0.0.0:${port}
WorkingDirectory=${DATA_DIR}
Restart=always
+3
View File
@@ -16,6 +16,7 @@ type Settings struct {
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
VisitorAuditEnabled bool `json:"visitor_audit_enabled" default:"false"` // 是否允许公开访客事件写入审计日志,默认 false
SSRFProtectionEnabled bool `json:"ssrf_protection_enabled" default:"false"` // 是否启用 SSRF 防护,默认关闭
EulaAccepted bool `json:"eula_accepted" default:"false"`
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
@@ -54,6 +55,7 @@ const (
AutoDiscoveryKeyKey = "auto_discovery_key"
ScriptDomainKey = "script_domain"
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
VisitorAuditEnabledKey = "visitor_audit_enabled"
SSRFProtectionEnabledKey = "ssrf_protection_enabled"
EulaAcceptedKey = "eula_accepted"
BaseScriptsURLKey = "base_scripts_url"
@@ -73,6 +75,7 @@ const (
LoginNotificationKey = "login_notification"
TrafficLimitPercentageKey = "traffic_limit_percentage"
UpdatedAtKey = "updated_at"
XtermjsSettingsKey = "xtermjs_settings"
ThemeMarketSourcesKey = "theme_market_sources"
PluginMarketSourcesKey = "plugin_market_sources"
)
+14 -36
View File
@@ -25,24 +25,17 @@ type reportTrafficValues struct {
timestamp time.Time
hasUp bool
totalUp int64
totalUpAt time.Time
hasDown bool
totalDown int64
totalDownAt time.Time
}
var (
reportTrafficStates sync.Map
reportWriteMu sync.Mutex
)
var reportTrafficStates sync.Map
const (
reportBatchInterval = 3 * time.Second
reportBatchQueueSize = 512
reportBatchQueueSize = 4096
pingBatchMaxRecords = 512
reportBatchWriteTimeout = 10 * time.Second
// Longer gaps have no reliable per-bucket traffic attribution.
maxTrafficReportGap = 15 * time.Minute
)
var (
@@ -349,11 +342,6 @@ func writeReportBatch(ctx context.Context, reports []v2.Report) ([]v2.Report, er
}
defer storeOperations.ReleaseShared()
// Serialize baseline reads and commits, including direct writes without
// the background batcher.
reportWriteMu.Lock()
defer reportWriteMu.Unlock()
s := GetStore()
if s == nil {
return nil, fmt.Errorf("metric store not enabled")
@@ -373,65 +361,55 @@ func writeReportBatch(ctx context.Context, reports []v2.Report) ([]v2.Report, er
state.mu.Unlock()
}
if !values.initialized {
totalUp, hasUp, err := s.LatestBefore(ctx, MetricNetTotalUp, report.UUID, report.UpdatedAt)
totalUp, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
if err != nil {
logger.Errorf("metricstore", "failed to restore previous upload counter for %s: %v", report.UUID, err)
} else {
values.totalUp = int64(totalUp.Value)
values.totalUpAt = totalUp.Timestamp
values.totalUp = totalUp
values.hasUp = hasUp
}
totalDown, hasDown, err := s.LatestBefore(ctx, MetricNetTotalDown, report.UUID, report.UpdatedAt)
totalDown, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
if err != nil {
logger.Errorf("metricstore", "failed to restore previous download counter for %s: %v", report.UUID, err)
} else {
values.totalDown = int64(totalDown.Value)
values.totalDownAt = totalDown.Timestamp
values.totalDown = totalDown
values.hasDown = hasDown
}
values.initialized = true
// Keep only the restored baseline on failure. WriteBatch may have
// accepted raw samples before a rollup flush fails, so restoring
// again on retry could otherwise read this uncommitted batch.
state.mu.Lock()
state.reportTrafficValues = values
state.mu.Unlock()
}
if !values.timestamp.IsZero() && !report.UpdatedAt.After(values.timestamp) {
report.UpdatedAt = values.timestamp.Add(time.Millisecond)
}
trafficUp := int64(0)
if values.hasUp && !values.totalUpAt.IsZero() && report.UpdatedAt.Sub(values.totalUpAt) <= maxTrafficReportGap {
if values.hasUp {
trafficUp = TrafficCounterDelta(report.Network.TotalUp, values.totalUp)
}
trafficDown := int64(0)
if values.hasDown && !values.totalDownAt.IsZero() && report.UpdatedAt.Sub(values.totalDownAt) <= maxTrafficReportGap {
if values.hasDown {
trafficDown = TrafficCounterDelta(report.Network.TotalDown, values.totalDown)
}
points = append(points, reportMetricPoints(report, trafficUp, trafficDown)...)
values.timestamp = report.UpdatedAt
values.hasUp = true
values.totalUp = report.Network.TotalUp
values.totalUpAt = report.UpdatedAt
values.hasDown = true
values.totalDown = report.Network.TotalDown
values.totalDownAt = report.UpdatedAt
pendingStates[state] = values
prepared[i] = report
}
if err := s.WriteBatch(ctx, points); err != nil {
return nil, err
}
// Advance counters and timestamps only after success. Retried batches
// must replace the same samples, not manufacture resets or new timestamps.
// Persist the restored per-report traffic state even if the write below
// fails, so a slow or failing database does not re-issue the previous-
// counter queries on every batch.
for state, values := range pendingStates {
state.mu.Lock()
state.reportTrafficValues = values
state.mu.Unlock()
}
if err := s.WriteBatch(ctx, points); err != nil {
return nil, err
}
return prepared, nil
}
-74
View File
@@ -51,7 +51,6 @@ func useReportTestStore(t *testing.T, policy *metric.RollupPolicy) *metric.Store
type reportCounterFault struct {
remaining atomic.Int32
denied atomic.Int32
failWrite atomic.Bool
}
func (f *reportCounterFault) denyRollupRead() bool {
@@ -89,9 +88,6 @@ func useReportCounterFailureStore(t *testing.T) (*metric.Store, *reportCounterFa
driver := &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
conn.RegisterAuthorizer(func(op int, arg1, _, _ string) int {
if op == sqlite3.SQLITE_TRANSACTION && arg1 == "BEGIN" && fault.failWrite.Load() {
return sqlite3.SQLITE_DENY
}
if op == sqlite3.SQLITE_READ && arg1 == "metric_rollups" && fault.denyRollupRead() {
return sqlite3.SQLITE_DENY
}
@@ -170,76 +166,6 @@ func TestReportBatchCounterRestoreFailureInitializesStateOnce(t *testing.T) {
assertMetricValues(t, s, MetricTrafficDown, first.UUID, base.Add(-time.Second), base.Add(2*time.Second), []float64{0, 60})
}
func TestReportBatchRetryDoesNotCountHistoricalTraffic(t *testing.T) {
for _, existingBaseline := range []bool{false, true} {
t.Run(fmt.Sprintf("existing-baseline-%v", existingBaseline), func(t *testing.T) {
s, fault := useReportCounterFailureStore(t)
fault.remaining.Store(0)
ctx := context.Background()
base := time.Now().UTC().Truncate(time.Minute).Add(-2 * time.Minute)
report := v2.Report{
UUID: "retry-node", UpdatedAt: base,
Network: v2.NetworkReport{TotalUp: 12 << 30, TotalDown: 30 << 30},
}
if existingBaseline {
if _, err := writeReportBatch(ctx, []v2.Report{report}); err != nil {
t.Fatal(err)
}
}
first := report
first.UpdatedAt = base.Add(3 * time.Second)
first.Network.TotalUp += 100
first.Network.TotalDown += 200
second := first
second.UpdatedAt = base.Add(6 * time.Second)
second.Network.TotalUp += 50
second.Network.TotalDown += 60
pending := []v2.Report{first, second}
fault.failWrite.Store(true)
for attempt := 0; attempt < 2; attempt++ {
if err := writePendingReports(ctx, &pending); err == nil {
t.Fatal("expected injected rollup write failure")
}
if len(pending) != 2 {
t.Fatal("failed reports were removed from pending batch")
}
value, _ := reportTrafficStates.Load(report.UUID)
state := value.(*reportTrafficState)
state.mu.Lock()
baseline := state.reportTrafficValues
state.mu.Unlock()
if baseline.hasUp != existingBaseline || baseline.hasDown != existingBaseline ||
(existingBaseline && (baseline.totalUp != report.Network.TotalUp ||
baseline.totalDown != report.Network.TotalDown || !baseline.timestamp.Equal(base))) {
t.Fatal("failed write advanced the committed counter baseline")
}
}
fault.failWrite.Store(false)
if err := writePendingReports(ctx, &pending); err != nil {
t.Fatalf("retry failed: %v", err)
}
if len(pending) != 0 {
t.Fatal("successful reports remained pending")
}
wantUp, wantDown := []float64{0, 50}, []float64{0, 60}
if existingBaseline {
wantUp, wantDown = []float64{0, 100, 50}, []float64{0, 200, 60}
}
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), wantUp)
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), wantDown)
var sumUp, sumDown float64
for i := range wantUp {
sumUp += wantUp[i]
sumDown += wantDown[i]
}
assertMetricAggregate(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, sumUp, len(wantUp))
assertMetricAggregate(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, sumDown, len(wantDown))
})
}
}
func TestWriteReportStoresMinuteMetricsAndResetAwareTraffic(t *testing.T) {
ctx := context.Background()
policy := defaultRollupPolicy()
+11
View File
@@ -112,6 +112,9 @@ func Run(ctx Context) error {
if err := migrateLegacyClientInfo(db); err != nil {
return err
}
if err := migrateLegacyLoadNotification(db); err != nil {
return err
}
if err := migrateLegacyPingAllClientsExpansion(db); err != nil {
return err
}
@@ -172,6 +175,14 @@ func hasTableColumn(db *gorm.DB, tableName, columnName string) bool {
return false
}
func migrateLegacyLoadNotification(db *gorm.DB) error {
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
logger.InfoArgs("migration", "[>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
+3
View File
@@ -35,7 +35,10 @@ var legacyTimestampColumns = [...]timestampColumn{
{table: "sessions", column: "expires"},
{table: "sessions", column: "created_at"},
{table: "logs", column: "time"},
{table: "clipboards", column: "created_at"},
{table: "clipboards", column: "updated_at"},
{table: "offline_notifications", column: "last_notified"},
{table: "load_notifications", column: "last_notified"},
{table: "task_results", column: "finished_at"},
{table: "task_results", column: "created_at"},
{table: "records", column: "time"},
-64
View File
@@ -1,64 +0,0 @@
// Package notifications wires the built-in notification channels into the
// shared messageSender registry.
package notifications
import (
"encoding/json"
"errors"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/managedconfig"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/javascript"
"github.com/komari-monitor/komari/utils/messageSender/webhook"
)
// Initialize registers the built-in channels and seeds their defaults.
func Initialize() (err error) {
if err := webhook.Register(); err != nil {
return err
}
registered := []string{"webhook"}
defer func() {
if err != nil {
for _, id := range registered {
err = errors.Join(err, messageSender.UnregisterNotificationChannel(id))
}
}
}()
if err := javascript.Register(); err != nil {
return err
}
registered = append(registered, javascript.ChannelID)
for _, item := range messageSender.ListNotificationChannels() {
if _, err := database.GetMessageSenderConfigByName(item.ID); err == nil {
continue
}
values := map[string]any{}
for _, field := range managedconfig.Items(item.Configuration) {
if field.Key != "" {
values[field.Key] = managedconfig.DefaultValue(field)
}
}
raw, err := json.Marshal(values)
if err != nil {
return err
}
if err := database.SaveMessageSenderConfig(&models.MessageSenderProvider{
Name: item.ID,
Addition: string(raw),
}); err != nil {
return err
}
}
return nil
}
// Shutdown unregisters the built-in channels and releases their runtimes.
func Shutdown() error {
return errors.Join(
messageSender.UnregisterNotificationChannel(javascript.ChannelID),
messageSender.UnregisterNotificationChannel("webhook"),
)
}
-160
View File
@@ -1,160 +0,0 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/dop251/goja"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/jsruntime"
"github.com/komari-monitor/komari/utils/messageSender"
)
// registerNotificationChannel registers one channel owned by a plugin. The
// channel is tied to the current runtime, so unloading the plugin removes it.
func (m *Manager) registerNotificationChannel(
short string,
id string,
configuration models.Configuration,
handler goja.Callable,
host *jsruntime.Host,
) error {
inst := m.instanceFor(short)
if inst == nil {
return fmt.Errorf("plugin %q is not loaded", short)
}
if host == nil {
return fmt.Errorf("plugin %q notification runtime is unavailable", short)
}
notificationChannel := &pluginNotificationChannel{
short: short,
host: host,
handler: handler,
}
inst.mu.Lock()
defer inst.mu.Unlock()
if inst.runtime == nil {
return fmt.Errorf("plugin %q notification runtime is closed", short)
}
if err := messageSender.RegisterNotificationChannel(id, configuration, notificationChannel); err != nil {
return err
}
if inst.notificationChannels == nil {
inst.notificationChannels = make(map[string]struct{})
}
inst.notificationChannels[id] = struct{}{}
return nil
}
type pluginNotificationChannel struct {
short string
host *jsruntime.Host
handler goja.Callable
}
func (c *pluginNotificationChannel) Send(ctx context.Context, notification messageSender.Notification, config map[string]any) error {
if c.host == nil || c.handler == nil {
return fmt.Errorf("plugin %q notification channel is closed", c.short)
}
result := make(chan error, 1)
var once sync.Once
finish := func(err error) {
once.Do(func() { result <- err })
}
queued := c.host.RunOnLoop(func(vm *goja.Runtime) {
var value goja.Value
runErr := c.host.RunJob(vm, "plugin notification "+c.short, func() error {
raw, err := json.Marshal(notification)
if err != nil {
return err
}
var notificationValue map[string]any
if err := json.Unmarshal(raw, &notificationValue); err != nil {
return err
}
var callErr error
value, callErr = c.handler(
goja.Undefined(),
vm.ToValue(notificationValue),
vm.ToValue(config),
)
return callErr
})
if runErr != nil {
finish(runErr)
return
}
_, ok := value.Export().(*goja.Promise)
if !ok {
finish(nil)
return
}
then, ok := goja.AssertFunction(value.ToObject(vm).Get("then"))
if !ok {
finish(fmt.Errorf("plugin %q notification channel returned a Promise without then", c.short))
return
}
onFulfilled := vm.ToValue(func(goja.FunctionCall) goja.Value {
finish(nil)
return goja.Undefined()
})
onRejected := vm.ToValue(func(call goja.FunctionCall) goja.Value {
finish(fmt.Errorf("plugin %q notification channel failed: %v", c.short, call.Argument(0)))
return goja.Undefined()
})
if _, err := then(value, onFulfilled, onRejected); err != nil {
finish(fmt.Errorf("plugin %q notification channel Promise failed: %w", c.short, err))
}
})
if !queued {
return fmt.Errorf("plugin %q notification runtime is closed", c.short)
}
timeout := c.host.Timeout()
if deadline, ok := ctx.Deadline(); ok {
if remaining := time.Until(deadline); remaining < timeout {
timeout = remaining
}
}
if timeout <= 0 {
return context.DeadlineExceeded
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-result:
return err
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return fmt.Errorf("plugin %q notification channel timed out", c.short)
}
}
func (c *pluginNotificationChannel) Unload() error {
return nil
}
func (m *Manager) unregisterNotificationChannels(inst *Instance) error {
if inst == nil {
return nil
}
inst.mu.Lock()
ids := make([]string, 0, len(inst.notificationChannels))
for id := range inst.notificationChannels {
ids = append(ids, id)
}
inst.notificationChannels = nil
inst.mu.Unlock()
var firstErr error
for _, id := range ids {
if err := messageSender.UnregisterNotificationChannel(id); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
+11 -19
View File
@@ -113,16 +113,15 @@ type Manager struct {
// Instance is one loaded plugin runtime plus its host bindings.
type Instance struct {
mu sync.RWMutex
info models.Plugin
dir string
runtime *jsruntime.Runtime
host *jsruntime.Host
handlers map[string]goja.Callable // "METHOD path" -> current route handler
statics map[string]*staticConfig // mount path -> static folder config
rpcMethods map[string]goja.Callable // registered RPC method -> JS handler
cronJobs []string // scheduler job names, removed on unload
notificationChannels map[string]struct{}
mu sync.RWMutex
info models.Plugin
dir string
runtime *jsruntime.Runtime
host *jsruntime.Host
handlers map[string]goja.Callable // "METHOD path" -> current route handler
statics map[string]*staticConfig // mount path -> static folder config
rpcMethods map[string]goja.Callable // registered RPC method -> JS handler
cronJobs []string // scheduler job names, removed on unload
}
// global is the process-wide plugin manager.
@@ -306,8 +305,8 @@ func (m *Manager) load(short string) error {
if rt.HasFunction("load") {
if err := rt.CallVoid("load"); err != nil {
m.dropInstance(short, inst)
rt.Close()
m.dropInstance(short, inst)
return fmt.Errorf("plugin %q load() failed: %w", short, err)
}
}
@@ -329,15 +328,11 @@ func (m *Manager) load(short string) error {
// unload behavior. The caller must not hold any Manager lock.
func (m *Manager) dropInstance(short string, inst *Instance) {
m.mu.Lock()
defer m.mu.Unlock()
if m.instances[short] != inst {
m.mu.Unlock()
return // replaced by a newer load or already unloaded
}
delete(m.instances, short)
m.mu.Unlock()
_ = m.unregisterNotificationChannels(inst)
m.mu.Lock()
defer m.mu.Unlock()
inst.mu.Lock()
for method := range inst.rpcMethods {
rpc.Unregister(method)
@@ -390,9 +385,6 @@ func (m *Manager) unload(short string) error {
}
}
}
if err := m.unregisterNotificationChannels(inst); err != nil && unloadErr == nil {
unloadErr = err
}
m.mu.Lock()
inst.mu.Lock()
for method := range inst.rpcMethods {
-29
View File
@@ -18,7 +18,6 @@ import (
"github.com/dop251/goja_nodejs/buffer"
"github.com/dop251/goja_nodejs/require"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/jsruntime"
"github.com/komari-monitor/komari/pkg/jsruntime/httpbody"
"github.com/komari-monitor/komari/pkg/rpc"
@@ -54,8 +53,6 @@ import (
// result or rejects with an Error
// carrying code/message/data
// server.registerRPC(method, handler) register a plugin-owned RPC method
// server.registerNotificationChannel(id, configuration, handler)
// register a notification channel
// server.getConfig() resolve the saved plugin configuration
// server.cron(expr, fn) run fn on the plugin event loop each
// time the cron expression fires
@@ -225,32 +222,6 @@ func (m *Manager) registerServerModule(host *jsruntime.Host, registry *require.R
}
return goja.Undefined()
})
_ = exports.Set("registerNotificationChannel", func(call goja.FunctionCall) goja.Value {
id := strings.TrimSpace(call.Argument(0).String())
handler, ok := goja.AssertFunction(call.Argument(2))
if id == "" {
panic(vm.NewTypeError("server.registerNotificationChannel requires a channel id"))
}
if !ok {
panic(vm.NewTypeError("server.registerNotificationChannel requires a function handler"))
}
rawConfiguration, err := exportJSValue(call.Argument(1))
if err != nil {
panic(vm.NewTypeError("server.registerNotificationChannel requires a managed configuration object"))
}
configurationJSON, err := json.Marshal(rawConfiguration)
if err != nil {
panic(vm.NewTypeError("server.registerNotificationChannel requires a managed configuration object"))
}
var configuration models.Configuration
if err := json.Unmarshal(configurationJSON, &configuration); err != nil {
panic(vm.NewTypeError("server.registerNotificationChannel requires a managed configuration object"))
}
if err := m.registerNotificationChannel(inst.info.Short, id, configuration, handler, host); err != nil {
panic(vm.NewGoError(err))
}
return goja.Undefined()
})
_ = exports.Set("getConfig", func(call goja.FunctionCall) goja.Value {
promise, resolve, reject := vm.NewPromise()
go func() {
+3 -7
View File
@@ -5,9 +5,9 @@ import (
"fmt"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/notifications"
"github.com/komari-monitor/komari/utils/geoip"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/web/oauth"
)
@@ -18,12 +18,8 @@ func (a *App) InitProviders() error {
go geoip.InitGeoIp()
a.addCleanup("geoip", func(context.Context) error { return geoip.Shutdown() })
if err := notifications.Initialize(); err != nil {
return fmt.Errorf("initialize notification channels: %w", err)
}
a.addCleanup("notification-channels", func(context.Context) error {
return notifications.Shutdown()
})
messageSender.Initialize()
a.addCleanup("message-sender", func(context.Context) error { return messageSender.Shutdown() })
return nil
}
+10
View File
@@ -14,6 +14,7 @@ import (
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/lifecycle"
@@ -22,6 +23,7 @@ import (
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/utils/geoip"
logger "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/web/api"
"github.com/komari-monitor/komari/web/oauth"
@@ -74,6 +76,11 @@ func (a *App) registerReloadHandlers(cors *security.CorsController) {
go geoip.InitGeoIp()
}
})
a.reload.Register("message-sender", func(event config.ConfigEvent) {
if event.IsChanged(config.NotificationMethodKey) {
go messageSender.Initialize()
}
})
a.reload.Register("cors", func(event config.ConfigEvent) { cors.Update(event) })
}
@@ -181,6 +188,9 @@ func registerScheduledWork() {
if err := tasks.ReloadPingSchedule(); err != nil {
logger.ErrorArgs("server", "Failed to reload ping schedule:", err)
}
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
logger.ErrorArgs("server", "Failed to reload load notification schedule:", err)
}
if err := scheduler.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
logger.ErrorArgs("server", "Failed to add cleanup scheduled task:", err)
}
+1 -1
View File
@@ -365,7 +365,7 @@ const value = Buffer.from("komari").toString("hex");
| `domainToASCII/domainToUnicode` | `可用` |
| 旧式 `parse/format/resolve/resolveObject/urlToHttpOptions` | `未实现` |
`URL` 和 `URLSearchParams` 会作为全局变量注入,也可以从模块取得:
`URL` 和 `URLSearchParams` 不会自动成为全局变量,应从模块取得:
```js
const { URL, URLSearchParams } = require("node:url");
+2 -4
View File
@@ -4,17 +4,15 @@ import (
_ "embed"
"fmt"
nodeurl "github.com/dop251/goja_nodejs/url"
"github.com/komari-monitor/komari/pkg/jsruntime/xhr"
)
// JavaScript compatibility: injects bounded timer APIs, console, URL APIs,
// Fetch API, XMLHttpRequest, and queueMicrotask.
// JavaScript compatibility: injects bounded timer APIs, console, Fetch API,
// XMLHttpRequest, and queueMicrotask.
func (r *Runtime) injectGlobals() error {
if err := r.consoleMod.Inject(r.vm); err != nil {
return fmt.Errorf("inject console: %w", err)
}
nodeurl.Enable(r.vm)
if err := r.timersMod.Inject(r.vm); err != nil {
return fmt.Errorf("inject timers: %w", err)
}
+2 -56
View File
@@ -22,15 +22,6 @@ import (
type Module struct {
runtime *bridge.Runtime
allowListen bool
sockets sync.Map
}
type socketState struct {
connection net.Conn
resourceID uint64
readerDone chan struct{}
detaching atomic.Bool
closed atomic.Bool
}
func New(runtime *bridge.Runtime, allowListen bool) *Module {
@@ -344,41 +335,6 @@ func (m *Module) newNetSocket(vm *goja.Runtime, connection net.Conn, onClose fun
return socket
}
// AttachSocket configures a JavaScript socket around an established
// connection. It is shared with the TLS module after a handshake completes.
func (m *Module) AttachSocket(vm *goja.Runtime, socket *goja.Object, connection net.Conn, onClose func()) bool {
return m.configureNetSocket(vm, socket, connection, onClose)
}
// DetachSocket stops a socket's reader and transfers ownership of its
// connection to the caller. It is used to upgrade an SMTP connection with
// STARTTLS without closing the underlying TCP stream.
func (m *Module) DetachSocket(socket *goja.Object) (net.Conn, error) {
value, ok := m.sockets.Load(socket)
if !ok {
return nil, errors.New("socket is not connected")
}
state := value.(*socketState)
if !state.detaching.CompareAndSwap(false, true) {
return nil, errors.New("socket is already being upgraded")
}
if err := state.connection.SetReadDeadline(time.Now()); err != nil {
state.detaching.Store(false)
return nil, err
}
<-state.readerDone
if state.closed.Load() {
return nil, errors.New("socket closed during upgrade")
}
m.sockets.Delete(socket)
m.runtime.RemoveResource(state.resourceID)
if err := state.connection.SetDeadline(time.Time{}); err != nil {
_ = state.connection.Close()
return nil, err
}
return state.connection, nil
}
func (m *Module) configureNetSocket(vm *goja.Runtime, socket *goja.Object, connection net.Conn, onClose func()) bool {
localHost, localPort := splitNetAddress(connection.LocalAddr())
remoteHost, remotePort := splitNetAddress(connection.RemoteAddr())
@@ -400,18 +356,12 @@ func (m *Module) configureNetSocket(vm *goja.Runtime, socket *goja.Object, conne
}
return false
}
state := &socketState{
connection: connection,
resourceID: resourceID,
readerDone: make(chan struct{}),
}
m.sockets.Store(socket, state)
var encoding atomic.Value
encoding.Store("")
var closed atomic.Bool
var writes writequeue.Queue
closeSocket := func() {
if state.closed.CompareAndSwap(false, true) {
m.sockets.Delete(socket)
if closed.CompareAndSwap(false, true) {
m.runtime.RemoveResource(resourceID)
_ = connection.Close()
if onClose != nil {
@@ -518,7 +468,6 @@ func (m *Module) configureNetSocket(vm *goja.Runtime, socket *goja.Object, conne
panic(vm.NewGoError(fmt.Errorf("net.Socket.unref is not supported by jsruntime; the event loop is host-driven")))
})
go func() {
defer close(state.readerDone)
data := make([]byte, 32*1024)
for {
count, err := connection.Read(data)
@@ -541,9 +490,6 @@ func (m *Module) configureNetSocket(vm *goja.Runtime, socket *goja.Object, conne
<-delivered
}
if err != nil {
if state.detaching.Load() {
return
}
closeSocket()
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "net.Socket close", func() error {
-1
View File
@@ -25,7 +25,6 @@ func (r *Runtime) registerNodeModules(registry *require.Registry) {
r.registerNodeModule(registry, "fs", r.fsModule.Load)
r.registerNodeModule(registry, "child_process", r.childProcessModule.Load)
r.registerNodeModule(registry, "net", r.netModule.Load)
r.registerNodeModule(registry, "tls", r.tlsModule.Load)
r.registerNodeModule(registry, "http", r.httpModule.Load)
r.registerNodeModule(registry, "stream", streammodule.Load)
r.registerNodeModule(registry, "stream/promises", streammodule.LoadPromises)
+1 -3
View File
@@ -22,6 +22,7 @@ import (
_ "github.com/dop251/goja_nodejs/buffer"
"github.com/dop251/goja_nodejs/eventloop"
"github.com/dop251/goja_nodejs/require"
_ "github.com/dop251/goja_nodejs/url"
_ "github.com/dop251/goja_nodejs/util"
childprocess "github.com/komari-monitor/komari/pkg/jsruntime/child_process"
"github.com/komari-monitor/komari/pkg/jsruntime/console"
@@ -34,7 +35,6 @@ import (
pathmodule "github.com/komari-monitor/komari/pkg/jsruntime/path"
processmodule "github.com/komari-monitor/komari/pkg/jsruntime/process"
"github.com/komari-monitor/komari/pkg/jsruntime/timers"
tlsmodule "github.com/komari-monitor/komari/pkg/jsruntime/tls"
)
const defaultTimeout = 30 * time.Second
@@ -123,7 +123,6 @@ type Runtime struct {
processModule *processmodule.Module
childProcessModule *childprocess.Module
netModule *netmodule.Module
tlsModule *tlsmodule.Module
httpModule *httpmodule.Module
cryptoModule *cryptomodule.Module
resourceMu sync.Mutex
@@ -238,7 +237,6 @@ func New(script string, options Options) (*Runtime, error) {
})
runtime.childProcessModule = childprocess.New(host, filesystem, options.AllowExec, maxChildOutputBytes)
runtime.netModule = netmodule.New(host, options.AllowListen)
runtime.tlsModule = tlsmodule.New(host, runtime.netModule)
runtime.httpModule = httpmodule.New(host, options.AllowListen, maxHTTPBodyBytes)
runtime.cryptoModule = cryptomodule.New(host)
runtime.registerNodeModules(registry)
-21
View File
@@ -29,8 +29,6 @@ func TestNewReturnsRuntimeWithInjectedGlobals(t *testing.T) {
typeof console.warn === "function" &&
typeof fetch === "function" &&
typeof XMLHttpRequest === "function" &&
typeof URL === "function" &&
typeof URLSearchParams === "function" &&
typeof Promise === "function" &&
typeof require === "function" &&
typeof queueMicrotask === "function" &&
@@ -51,25 +49,6 @@ func TestNewReturnsRuntimeWithInjectedGlobals(t *testing.T) {
}
}
func TestURLSearchParamsGlobal(t *testing.T) {
runtime, err := New(`
function sendMessage() {
const params = new URLSearchParams();
params.set("chat_id", "-100 123");
params.set("text", "通知 & test");
return params.toString() === "chat_id=-100+123&text=%E9%80%9A%E7%9F%A5+%26+test";
}
`, Options{Console: io.Discard, Timeout: time.Second})
if err != nil {
t.Fatal(err)
}
defer runtime.Close()
if err := runtime.Call("sendMessage"); err != nil {
t.Fatal(err)
}
}
func TestConsoleMethodsWriteMessagesAndStacks(t *testing.T) {
var output bytes.Buffer
runtime, err := New(`
-163
View File
@@ -1,163 +0,0 @@
package tlsmodule
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"strconv"
"github.com/dop251/goja"
"github.com/komari-monitor/komari/pkg/jsruntime/events"
"github.com/komari-monitor/komari/pkg/jsruntime/internal/bridge"
netmodule "github.com/komari-monitor/komari/pkg/jsruntime/net"
)
// Module provides the subset of node:tls needed by notification plugins.
type Module struct {
runtime *bridge.Runtime
netModule *netmodule.Module
}
func New(runtime *bridge.Runtime, netModule *netmodule.Module) *Module {
return &Module{runtime: runtime, netModule: netModule}
}
func (m *Module) Load(vm *goja.Runtime, module *goja.Object) {
exports := vm.NewObject()
connect := func(call goja.FunctionCall) goja.Value { return m.connect(vm, call) }
_ = exports.Set("connect", connect)
_ = exports.Set("createConnection", connect)
_ = module.Set("exports", exports)
}
func (m *Module) connect(vm *goja.Runtime, call goja.FunctionCall) *goja.Object {
options, ok := call.Argument(0).(*goja.Object)
if !ok {
panic(vm.NewTypeError("tls.connect requires an options object"))
}
host := stringOption(options, "host")
port := intOption(options, "port")
serverName := stringOption(options, "servername")
if serverName == "" {
serverName = host
}
var sourceSocket *goja.Object
if value := options.Get("socket"); value != nil && !goja.IsUndefined(value) && !goja.IsNull(value) {
sourceSocket, ok = value.(*goja.Object)
if !ok {
panic(vm.NewTypeError("tls.connect socket must be a net.Socket"))
}
}
if sourceSocket == nil && (host == "" || port < 1) {
panic(vm.NewTypeError("tls.connect requires host and port"))
}
if serverName == "" {
panic(vm.NewTypeError("tls.connect requires servername when upgrading a socket"))
}
config := &tls.Config{ServerName: serverName, MinVersion: tls.VersionTLS12}
if ca := stringOption(options, "ca"); ca != "" {
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM([]byte(ca)) {
panic(vm.NewTypeError("tls.connect ca does not contain a certificate"))
}
config.RootCAs = roots
}
socket := events.NewEmitter(vm)
_ = socket.Set("connecting", true)
_ = socket.Set("encrypted", true)
_ = socket.Set("authorized", false)
_ = socket.Set("servername", serverName)
ctx, cancel := context.WithTimeout(context.Background(), m.runtime.Timeout())
_ = socket.Set("destroy", func() *goja.Object {
cancel()
return socket
})
if callback, ok := goja.AssertFunction(call.Argument(1)); ok {
once, _ := goja.AssertFunction(socket.Get("once"))
_, _ = once(socket, vm.ToValue("secureConnect"), vm.ToValue(callback))
}
go m.establish(ctx, cancel, socket, sourceSocket, host, port, config)
return socket
}
func (m *Module) establish(
ctx context.Context,
cancel context.CancelFunc,
socket *goja.Object,
sourceSocket *goja.Object,
host string,
port int,
config *tls.Config,
) {
defer cancel()
var connection net.Conn
var err error
if sourceSocket != nil {
connection, err = m.netModule.DetachSocket(sourceSocket)
} else {
connection, err = (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
}
if err != nil {
m.emitError(socket, err)
return
}
resourceID := m.runtime.AddResource(func() { _ = connection.Close() })
if resourceID == 0 {
return
}
secureConnection := tls.Client(connection, config)
if err := secureConnection.HandshakeContext(ctx); err != nil {
m.runtime.RemoveResource(resourceID)
_ = connection.Close()
m.emitError(socket, fmt.Errorf("TLS handshake failed: %w", err))
return
}
queued := m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "tls secureConnect", func() error {
if !m.netModule.AttachSocket(vm, socket, secureConnection, nil) {
return fmt.Errorf("TLS socket runtime is closed")
}
m.runtime.RemoveResource(resourceID)
_ = socket.Set("authorized", true)
return events.Emit(vm, socket, "secureConnect")
})
})
if !queued {
m.runtime.RemoveResource(resourceID)
_ = secureConnection.Close()
}
}
func (m *Module) emitError(socket *goja.Object, err error) {
m.runtime.RunOnLoop(func(vm *goja.Runtime) {
_ = m.runtime.RunJob(vm, "tls error", func() error {
_ = socket.Set("connecting", false)
return events.Emit(vm, socket, "error", vm.NewGoError(err))
})
})
}
func stringOption(options *goja.Object, name string) string {
value := options.Get(name)
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return ""
}
return value.String()
}
func intOption(options *goja.Object, name string) int {
value := options.Get(name)
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
return 0
}
return int(value.ToInteger())
}
+1 -5
View File
@@ -30,11 +30,7 @@ var (
func getDigestEncoder() (*zstd.Encoder, error) {
digestEncoderOnce.Do(func() {
digestEncoder, digestEncoderErr = zstd.NewWriter(nil,
zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(1)),
zstd.WithEncoderConcurrency(1),
zstd.WithLowerEncoderMem(true),
)
digestEncoder, digestEncoderErr = zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(1)))
})
return digestEncoder, digestEncoderErr
}
+13 -32
View File
@@ -6,22 +6,19 @@ import (
)
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"
MethodAgentFile = "agent.file"
MethodAgentFileResult = "agent.file.result"
MethodAgentStartupConfig = "agent.startupConfig"
MethodAgentStartupConfigResult = "agent.startupConfig.result"
MethodAgentSwitchVersion = "agent.switchVersion"
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"
MethodAgentFile = "agent.file"
MethodAgentFileResult = "agent.file.result"
)
type Request struct {
@@ -185,22 +182,6 @@ type TerminalRequestParams struct {
RequestID string `json:"request_id"`
}
type StartupConfigParams struct {
RequestID string `json:"request_id"`
}
// Config contains every effective startup setting as a flat object, including
// credentials. It must never be exposed through public node information.
type StartupConfigResult struct {
RequestID string `json:"request_id"`
Config map[string]any `json:"config,omitempty"`
Error string `json:"error,omitempty"`
}
type SwitchVersionParams struct {
Version string `json:"version"`
}
// FileOperation is metadata-only. File contents travel through the dedicated
// HTTP transfer endpoint rather than through JSON-RPC.
type FileOperation struct {
+15
View File
@@ -0,0 +1,15 @@
package messageSender
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/telegram"
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
)
func All() {
}
+118
View File
@@ -0,0 +1,118 @@
package bark
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type BarkSender struct {
Addition
}
func (b *BarkSender) GetName() string {
return "bark"
}
func (b *BarkSender) GetConfiguration() factory.Configuration {
return &b.Addition
}
func (b *BarkSender) Init() error {
// 验证 ServerURL 格式
if b.Addition.ServerURL != "" {
if _, err := url.Parse(b.Addition.ServerURL); err != nil {
return fmt.Errorf("invalid server URL: %v", err)
}
}
return nil
}
func (b *BarkSender) Destroy() error {
return nil
}
func (b *BarkSender) SendTextMessage(message, title string) error {
if b.Addition.DeviceKey == "" {
return fmt.Errorf("device key is required")
}
if message == "" {
return fmt.Errorf("message is empty")
}
// 准备请求数据
payload := map[string]interface{}{
"body": message,
"device_key": b.Addition.DeviceKey,
}
// 如果有标题,添加标题
if title != "" {
payload["title"] = title
}
// 添加可选参数
if b.Addition.Icon != "" {
payload["icon"] = b.Addition.Icon
}
if b.Addition.Level != "" {
payload["level"] = b.Addition.Level
}
// 序列化 JSON
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %v", err)
}
// 构建请求 URL
serverURL := b.Addition.ServerURL
if serverURL == "" {
serverURL = "https://api.day.app"
}
// 确保 URL 以正确的格式结尾
serverURL = strings.TrimRight(serverURL, "/")
requestURL := fmt.Sprintf("%s/push", serverURL)
// 发送 HTTP POST 请求
resp, err := http.Post(requestURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bark API returned non-OK status: %d", resp.StatusCode)
}
// 解析响应
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// 如果解析失败,认为发送成功(某些 Bark 服务器可能返回纯文本)
return nil
}
// 检查 Bark API 响应
if result.Code != 200 {
return fmt.Errorf("bark API error (code: %d): %s", result.Code, result.Message)
}
return nil
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*BarkSender)(nil)
+18
View File
@@ -0,0 +1,18 @@
package bark
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
ServerURL string `json:"server_url" required:"true" default:"https://api.day.app" help:"Bark server URL, e.g., https://api.day.app or your self-hosted server address"`
DeviceKey string `json:"device_key" required:"true" help:"Your Bark device key, which can be found in the Bark App"`
Icon string `json:"icon" help:"Push notification icon, supports URL or system icon name"`
Level string `json:"level" type:"option" default:"timeSensitive" options:"active,timeSensitive,passive,critical" help:"Push notification level: active, timeSensitive (default), passive, critical"`
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &BarkSender{}
})
}
+289
View File
@@ -0,0 +1,289 @@
package email
import (
"bytes"
"crypto/tls"
"fmt"
"mime"
"mime/quotedprintable"
"net/mail"
"net/smtp"
"strconv"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type EmailSender struct {
Addition
}
// loginAuth 是一个更宽松的 SMTP 认证实现,支持更多 SMTP 服务器
// 它不会严格验证服务器主机名,从而兼容微软邮箱、网易邮箱等服务
type loginAuth struct {
username string
password string
host string
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:", "User Name":
return []byte(a.username), nil
case "Password:", "password:":
return []byte(a.password), nil
default:
// 某些服务器可能发送 base64 编码的提示
// 尝试返回用户名或密码
prompt := strings.ToLower(strings.TrimSpace(string(fromServer)))
if strings.Contains(prompt, "user") {
return []byte(a.username), nil
}
return []byte(a.password), nil
}
}
return nil, nil
}
// plainAuthWithoutCheck 是一个不检查主机名的 PlainAuth 实现
// 用于解决某些 SMTP 服务器主机名与配置不匹配的问题
type plainAuthWithoutCheck struct {
identity string
username string
password string
host string
}
func (a *plainAuthWithoutCheck) Start(server *smtp.ServerInfo) (string, []byte, error) {
resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
return "PLAIN", resp, nil
}
func (a *plainAuthWithoutCheck) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
return nil, fmt.Errorf("unexpected server challenge")
}
return nil, nil
}
func (e *EmailSender) GetName() string {
return "email"
}
func (e *EmailSender) GetConfiguration() factory.Configuration {
return &e.Addition
}
func (e *EmailSender) Init() error {
return nil
}
func (e *EmailSender) Destroy() error {
return nil
}
func (e *EmailSender) SendTextMessage(message, title string) error {
if e.Addition.Host == "" || e.Addition.Sender == "" || e.Addition.Username == "" || e.Addition.Password == "" || e.Addition.Receiver == "" {
return fmt.Errorf("email sending is not fully configured")
}
// 使用更宽松的认证方式,优先尝试 PLAIN,如果失败则尝试 LOGIN
// 这样可以兼容更多的 SMTP 服务器,包括微软邮箱、网易邮箱等
var auth smtp.Auth
if e.Addition.UseLoginAuth {
// 使用 LOGIN 认证(适用于某些旧的或特殊的 SMTP 服务器)
auth = &loginAuth{
username: e.Addition.Username,
password: e.Addition.Password,
host: e.Addition.Host,
}
} else {
// 使用不检查主机名的 PLAIN 认证(适用于大多数现代 SMTP 服务器)
auth = &plainAuthWithoutCheck{
identity: "",
username: e.Addition.Username,
password: e.Addition.Password,
host: e.Addition.Host,
}
}
// Parse sender address (for MAIL FROM and header)
var senderAddr string
var senderHeader string
if addr, err := mail.ParseAddress(e.Addition.Sender); err == nil {
senderAddr = addr.Address
senderHeader = addr.String()
} else {
// Fallback: use raw string
senderAddr = e.Addition.Sender
senderHeader = e.Addition.Sender
}
// Parse recipients (support comma-separated list)
var rcptList []string
var rcptHeaderParts []string
if addrs, err := mail.ParseAddressList(e.Addition.Receiver); err == nil {
for _, a := range addrs {
rcptList = append(rcptList, a.Address)
rcptHeaderParts = append(rcptHeaderParts, a.String())
}
} else {
// Fallback simple split
parts := strings.Split(e.Addition.Receiver, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if a, err := mail.ParseAddress(p); err == nil {
rcptList = append(rcptList, a.Address)
rcptHeaderParts = append(rcptHeaderParts, a.String())
} else {
rcptList = append(rcptList, p)
rcptHeaderParts = append(rcptHeaderParts, p)
}
}
}
if len(rcptList) == 0 {
return fmt.Errorf("no valid recipient address parsed")
}
// RFC 2047 encode subject if non-ASCII
encodedSubject := mime.QEncoding.Encode("UTF-8", title)
// Encode body as quoted-printable to be safe with UTF-8 on servers lacking 8BITMIME
var bodyBuf bytes.Buffer
qp := quotedprintable.NewWriter(&bodyBuf)
if _, err := qp.Write([]byte(message)); err != nil {
return fmt.Errorf("failed to encode body: %w", err)
}
if err := qp.Close(); err != nil {
return fmt.Errorf("failed to finalize body encoding: %w", err)
}
contentType := "text/plain; charset=UTF-8"
// 检测模板是否包含HTML
trimmedMsg := strings.TrimSpace(message)
if strings.Contains(strings.ToLower(trimmedMsg), "<html") ||
strings.Contains(strings.ToLower(trimmedMsg), "<!doctype") ||
(strings.Contains(trimmedMsg, "<div") && strings.Contains(trimmedMsg, "</div>")) {
contentType = "text/html; charset=UTF-8"
}
// Compose headers
now := time.Now().UTC()
headers := []string{
"To: " + strings.Join(rcptHeaderParts, ", "),
"From: " + senderHeader,
"Subject: " + encodedSubject,
"MIME-Version: 1.0",
"Content-Type: " + contentType,
"Content-Transfer-Encoding: quoted-printable",
"Date: " + now.Format(time.RFC1123Z),
fmt.Sprintf("Message-ID: <%d@%s>", now.UnixNano(), e.Addition.Host),
}
fullMsg := []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyBuf.String())
addr := e.Addition.Host + ":" + strconv.Itoa(e.Addition.Port)
if e.Addition.UseSSL {
// Use TLS. If port is 465, prefer implicit TLS. Otherwise, use STARTTLS.
if e.Addition.Port == 465 {
// Implicit TLS (SMTPS)
tlsCfg := &tls.Config{ServerName: e.Addition.Host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("failed to establish implicit TLS connection: %w", err)
}
defer conn.Close()
c, err := smtp.NewClient(conn, e.Addition.Host)
if err != nil {
return fmt.Errorf("failed to create SMTP client over TLS: %w", err)
}
defer c.Close()
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Mail(senderAddr); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, rcpt := range rcptList {
if err = c.Rcpt(rcpt); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", rcpt, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
if _, err = w.Write(fullMsg); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return c.Quit()
} else {
// STARTTLS
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
if err = c.StartTLS(&tls.Config{ServerName: e.Addition.Host}); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Mail(senderAddr); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
for _, rcpt := range rcptList {
if err = c.Rcpt(rcpt); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", rcpt, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
if _, err = w.Write(fullMsg); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return c.Quit()
}
} else {
// Send without SSL/TLS (less secure). We still reuse the composed message and parsed addresses.
return smtp.SendMail(
addr,
auth,
senderAddr,
rcptList,
fullMsg,
)
}
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*EmailSender)(nil)
+22
View File
@@ -0,0 +1,22 @@
package email
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
Host string `json:"host" required:"true"`
Port int `json:"port" required:"true" default:"587"`
Username string `json:"username"`
Password string `json:"password"`
Sender string `json:"sender"`
Receiver string `json:"receiver"`
UseSSL bool `json:"use_ssl" default:"false"`
UseLoginAuth bool `json:"use_login_auth" default:"false" help:"Use LOGIN authentication method instead of PLAIN. Enable this if you encounter authentication errors with Microsoft (Outlook/Office365), NetEase (163.com), or other email providers"`
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &EmailSender{}
})
}
+43
View File
@@ -0,0 +1,43 @@
package empty
import (
"fmt"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
}
type EmptyProvider struct {
Addition
}
func (e *EmptyProvider) GetName() string {
return "empty"
}
func (e *EmptyProvider) GetConfiguration() factory.Configuration {
return &e.Addition
}
func (e *EmptyProvider) Init() error {
return nil
}
func (e *EmptyProvider) Destroy() error {
return nil
}
func (e *EmptyProvider) SendTextMessage(message, title string) error {
return fmt.Errorf("you are using an empty message sender, please check your configuration")
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &EmptyProvider{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*EmptyProvider)(nil)
+60
View File
@@ -0,0 +1,60 @@
package factory
import (
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/item"
)
var (
senders = make(map[string]IMessageSender)
senderConstructor = make(map[string]MessageSenderConstructor)
sendersAdditionalItems = make(map[string][]item.Item)
)
func RegisterMessageSender(constructor MessageSenderConstructor) {
sender := constructor()
senderConstructor[sender.GetName()] = constructor
if sender == nil {
panic("Message sender constructor returned nil")
}
if _, exists := senders[sender.GetName()]; exists {
logger.InfoArgs("message-sender", "Message sender already registered: "+sender.GetName())
}
senders[sender.GetName()] = sender
// 使用反射来提取提供程序的配置字段
config := sender.GetConfiguration()
items := item.Parse(config)
sendersAdditionalItems[sender.GetName()] = items
}
func GetSenderConfigs() map[string][]item.Item {
return sendersAdditionalItems
}
func GetAllMessageSenders() map[string]IMessageSender {
return senders
}
func GetConstructor(name string) (MessageSenderConstructor, bool) {
constructor, exists := senderConstructor[name]
return constructor, exists
}
func GetAllMessageSenderNames() []string {
names := make([]string, 0, len(senders))
for name := range senders {
names = append(names, name)
}
return names
}
func Initialize() {
for _, sender := range senders {
if err := sender.Init(); err != nil {
logger.Errorf("message-sender", "Failed to initialize message sender %s: %v", sender.GetName(), err)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
package factory
import "github.com/komari-monitor/komari/database/models"
type IMessageSender interface {
GetName() string
// 请务必返回 &Configuration{} 的指针
GetConfiguration() Configuration
SendTextMessage(message, title string) error
Init() error
Destroy() error
}
// IEventMessageSender 是可选接口,如果实现则可以接收结构化的事件消息
type IEventMessageSender interface {
SendEvent(event models.EventMessage) error
}
type Configuration interface{}
type MessageSenderConstructor func() IMessageSender
+80 -73
View File
@@ -1,80 +1,30 @@
package javascript
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/jsruntime"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/outboundhttp"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// DataDir confines notification scripts' filesystem and local module access.
var DataDir = "./data/notification/javascript"
// ChannelID is also the key of existing JavaScript configurations in the database.
const ChannelID = "Javascript"
const executionTimeout = 30 * time.Second
type JavaScriptSender struct {
mu sync.Mutex
script string
Addition
runtime *jsruntime.Runtime
}
func (j *JavaScriptSender) Send(ctx context.Context, notification messageSender.Notification, config map[string]any) error {
var addition Addition
if err := messageSender.DecodeConfiguration(config, &addition); err != nil {
return err
}
j.mu.Lock()
defer j.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
if err := j.ensureRuntime(addition.Script); err != nil {
return err
}
if !j.runtime.HasFunction("sendEvent") {
return j.runtime.Call("sendMessage", notification.Message, notification.Title)
}
// Preserve the event's JSON field names and nested payloads for legacy scripts.
raw, err := json.Marshal(notification.Event)
if err != nil {
return fmt.Errorf("marshal notification event: %w", err)
}
var event map[string]any
if err := json.Unmarshal(raw, &event); err != nil {
return fmt.Errorf("decode notification event: %w", err)
}
return j.runtime.Call("sendEvent", event)
func (j *JavaScriptSender) GetName() string {
return "Javascript"
}
func (j *JavaScriptSender) ensureRuntime(script string) error {
if j.runtime != nil && j.script == script {
return nil
}
j.closeRuntime()
if strings.TrimSpace(script) == "" {
return errors.New("JavaScript notification script is not configured")
}
if err := os.MkdirAll(DataDir, 0755); err != nil {
return fmt.Errorf("create JavaScript notification directory: %w", err)
}
runtime, err := jsruntime.New(script, jsruntime.Options{
BaseDir: DataDir,
NodeJS: true,
Timeout: executionTimeout,
HTTPClient: outboundhttp.NewClient(executionTimeout),
})
func (j *JavaScriptSender) GetConfiguration() factory.Configuration {
return &j.Addition
}
func (j *JavaScriptSender) Init() error {
runtime, err := jsruntime.New(j.Addition.Script, jsruntime.Options{})
if err != nil {
return err
}
@@ -82,28 +32,85 @@ func (j *JavaScriptSender) ensureRuntime(script string) error {
runtime.Close()
return errors.New("sendMessage function not defined or not callable in script")
}
j.script = script
if j.runtime != nil {
j.runtime.Close()
}
j.runtime = runtime
return nil
}
func (j *JavaScriptSender) closeRuntime() {
func (j *JavaScriptSender) Destroy() error {
if j.runtime != nil {
j.runtime.Close()
j.runtime = nil
}
j.script = ""
}
func (j *JavaScriptSender) Unload() error {
j.mu.Lock()
defer j.mu.Unlock()
j.closeRuntime()
return nil
}
func Register() error {
return messageSender.RegisterNotificationChannel(ChannelID, messageSender.ManagedConfiguration("JavaScript", Addition{}), &JavaScriptSender{})
func (j *JavaScriptSender) SendTextMessage(message, title string) error {
if err := j.ensureRuntime(); err != nil {
return err
}
return j.runtime.Call("sendMessage", message, title)
}
var _ messageSender.NotificationChannel = (*JavaScriptSender)(nil)
func (j *JavaScriptSender) SendEvent(event models.EventMessage) error {
if err := j.ensureRuntime(); err != nil {
return err
}
if !j.runtime.HasFunction("sendEvent") {
return j.fallbackToTextMessage(event)
}
eventJSON, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %v", err)
}
var eventMap map[string]interface{}
if err := json.Unmarshal(eventJSON, &eventMap); err != nil {
return fmt.Errorf("failed to unmarshal event: %v", err)
}
return j.runtime.Call("sendEvent", eventMap)
}
func (j *JavaScriptSender) ensureRuntime() error {
if j.runtime != nil {
return nil
}
return j.Init()
}
// fallbackToTextMessage formats structured events for scripts that only
// implement sendMessage.
func (j *JavaScriptSender) fallbackToTextMessage(event models.EventMessage) error {
message := fmt.Sprintf("%v%v%v\nEvent: %v\nMessage: %v\nTime: %s",
event.Emoji, event.Emoji, event.Emoji,
event.Event,
event.Message,
event.Time.UTC().Format("2006-01-02 15:04:05"))
if len(event.Clients) > 0 {
clientNames := make([]string, 0, len(event.Clients))
for _, client := range event.Clients {
name := client.Name
if name == "" {
name = client.UUID
}
clientNames = append(clientNames, name)
}
message = fmt.Sprintf("%v%v%v\nEvent: %v\nClients: %s\nMessage: %v\nTime: %s",
event.Emoji, event.Emoji, event.Emoji,
event.Event,
clientNames,
event.Message,
event.Time.UTC().Format("2006-01-02 15:04:05"))
}
return j.SendTextMessage(message, fmt.Sprint(event.Event))
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &JavaScriptSender{}
})
}
var _ factory.IMessageSender = (*JavaScriptSender)(nil)
+1 -1
View File
@@ -1,5 +1,5 @@
package javascript
type Addition struct {
Script string `json:"script" name:"Script" required:"true" type:"richtext" help:"Implement sendMessage(message, title) and optionally sendEvent(event). Return true or a Promise resolving to true. Available APIs include fetch, XMLHttpRequest, console, timers, Buffer and CommonJS require with Node compatibility modules. Files and local modules are confined to data/notification/javascript; command execution and listening on ports are disabled."`
Script string `json:"script" required:"true" type:"richtext" help:"JavaScript code (ES6 supported) that implements sendMessage(message, title) and optionally sendEvent(event) functions. Both should return a Promise or boolean. Available APIs: fetch(), xhr(), console.log()."`
}
+29
View File
@@ -0,0 +1,29 @@
package messageSender
import (
"encoding/json"
"fmt"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func LoadProvider(name string, addition string) error {
mu.Lock()
defer mu.Unlock()
constructor, exists := factory.GetConstructor(name)
if !exists {
return fmt.Errorf("message sender provider not found: %s", name)
}
provider := constructor()
err := json.Unmarshal([]byte(addition), provider.GetConfiguration())
if err != nil {
return fmt.Errorf("failed to load config for provider %s: %w", name, err)
}
provider.Init()
if currentProvider != nil {
currentProvider.Destroy()
}
currentProvider = provider
return nil
}
@@ -5,6 +5,7 @@ import (
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func TestParseTemplateFormatsEventTimeInLocalTimezone(t *testing.T) {
@@ -34,3 +35,22 @@ func TestParseTemplateFormatsAnyTypedEventField(t *testing.T) {
}
}
}
func Test(t *testing.T) {
senders := factory.GetAllMessageSenders()
if len(senders) == 0 {
t.Error("No message senders found")
return
}
cfg := factory.GetSenderConfigs()
if len(cfg) == 0 {
t.Error("No sender configs found")
return
}
LoadProvider("email", `{"host":"smtp.example.com","port":587,"username":"user","password":"pass"}`)
cp := CurrentProvider
if cp() == nil {
t.Error("Current provider is nil")
return
}
}
-19
View File
@@ -1,19 +0,0 @@
package messageSender
import (
"os"
"testing"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
)
func TestMain(m *testing.M) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:komari_message_sender_test?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
os.Exit(m.Run())
}
-289
View File
@@ -1,289 +0,0 @@
package messageSender
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"sync"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/managedconfig"
"gorm.io/gorm"
)
// Notification is the message passed to a notification channel.
type Notification struct {
Event models.EventMessage `json:"event"`
Title string `json:"title"`
Message string `json:"message"`
}
// NotificationChannel is implemented by built-in and plugin notification
// channels.
type NotificationChannel interface {
Send(context.Context, Notification, map[string]any) error
Unload() error
}
// NotificationChannelInfo describes a registered notification channel.
type NotificationChannelInfo struct {
ID string `json:"id"`
Configuration models.Configuration `json:"configuration"`
}
type notificationChannelEntry struct {
configuration models.Configuration
channel NotificationChannel
}
var notificationChannels = struct {
sync.RWMutex
entries map[string]*notificationChannelEntry
}{
entries: make(map[string]*notificationChannelEntry),
}
// RegisterNotificationChannel registers a channel. Registration fails for an
// empty or already-used ID.
func RegisterNotificationChannel(id string, configuration models.Configuration, notificationChannel NotificationChannel) error {
return registerNotificationChannel(id, configuration, notificationChannel, nil)
}
func registerNotificationChannel(
id string,
configuration models.Configuration,
notificationChannel NotificationChannel,
afterRegister func() error,
) error {
id = strings.TrimSpace(id)
if id == "" {
return errors.New("notification channel id is required")
}
if notificationChannel == nil {
return fmt.Errorf("notification channel %q is nil", id)
}
if configuration.Type == "" {
configuration.Type = models.ThemeConfigurationManaged
}
notificationChannels.Lock()
defer notificationChannels.Unlock()
if _, exists := notificationChannels.entries[id]; exists {
return fmt.Errorf("notification channel %q is already registered", id)
}
notificationChannels.entries[id] = &notificationChannelEntry{
configuration: configuration,
channel: notificationChannel,
}
if afterRegister != nil {
if err := afterRegister(); err != nil {
delete(notificationChannels.entries, id)
return err
}
}
return nil
}
// UnregisterNotificationChannel removes a channel after all calls using it
// have completed, then unloads it.
func UnregisterNotificationChannel(id string) error {
id = strings.TrimSpace(id)
notificationChannels.Lock()
found, exists := notificationChannels.entries[id]
if exists {
delete(notificationChannels.entries, id)
}
notificationChannels.Unlock()
if !exists {
return nil
}
return found.channel.Unload()
}
// CallNotificationChannel invokes one registered channel with the latest
// saved, defaulted, and resolved configuration.
func CallNotificationChannel(ctx context.Context, id string, notification Notification) error {
id = strings.TrimSpace(id)
notificationChannels.RLock()
found := notificationChannels.entries[id]
if found == nil {
notificationChannels.RUnlock()
return fmt.Errorf("notification channel %q is not registered", id)
}
configuration := found.configuration
notificationChannel := found.channel
defer notificationChannels.RUnlock()
values, err := resolveNotificationChannelConfiguration(id, configuration)
if err != nil {
return err
}
return notificationChannel.Send(ctx, notification, values)
}
// ListNotificationChannels returns a snapshot sorted by channel ID.
func ListNotificationChannels() []NotificationChannelInfo {
notificationChannels.RLock()
defer notificationChannels.RUnlock()
result := make([]NotificationChannelInfo, 0, len(notificationChannels.entries))
for id, found := range notificationChannels.entries {
result = append(result, NotificationChannelInfo{
ID: id,
Configuration: found.configuration,
})
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func NotificationChannelRegistered(id string) bool {
return notificationChannelRegistered(id)
}
func GetNotificationChannelConfiguration(id string) (models.Configuration, map[string]any, bool, error) {
configuration, exists := notificationChannelConfiguration(id)
if !exists {
return models.Configuration{}, nil, false, nil
}
values, err := resolveNotificationChannelConfiguration(id, configuration)
if err != nil {
return models.Configuration{}, nil, true, err
}
return configuration, values, true, nil
}
func notificationChannelRegistered(id string) bool {
notificationChannels.RLock()
defer notificationChannels.RUnlock()
return notificationChannels.entries[strings.TrimSpace(id)] != nil
}
func notificationChannelConfiguration(id string) (models.Configuration, bool) {
notificationChannels.RLock()
defer notificationChannels.RUnlock()
found := notificationChannels.entries[strings.TrimSpace(id)]
if found == nil {
return models.Configuration{}, false
}
return found.configuration, true
}
func resolveNotificationChannelConfiguration(id string, configuration models.Configuration) (map[string]any, error) {
values := map[string]any{}
cfg, err := database.GetMessageSenderConfigByName(id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("read notification channel %q configuration: %w", id, err)
}
if err == nil {
if err := json.Unmarshal([]byte(cfg.Addition), &values); err != nil {
return nil, fmt.Errorf("parse notification channel %q configuration: %w", id, err)
}
}
items := managedconfig.Items(configuration)
for _, item := range items {
if item.Key == "" {
continue
}
if _, exists := values[item.Key]; !exists {
values[item.Key] = managedconfig.DefaultValue(item)
}
}
if err := managedconfig.ResolveForOutput(values, items); err != nil {
return nil, fmt.Errorf("resolve notification channel %q configuration: %w", id, err)
}
return values, nil
}
// ManagedConfiguration builds a managed configuration declaration from a
// built-in channel's tagged configuration struct.
func ManagedConfiguration(name any, configuration any) models.Configuration {
value := reflect.ValueOf(configuration)
if value.Kind() == reflect.Pointer {
value = value.Elem()
}
items := make([]models.ManagedThemeConfigurationItem, 0, value.NumField())
for i := 0; i < value.NumField(); i++ {
field := value.Type().Field(i)
key := strings.Split(field.Tag.Get("json"), ",")[0]
if key == "" || key == "-" {
continue
}
itemType := field.Tag.Get("type")
switch {
case itemType == "option":
itemType = "select"
case itemType == "richtext":
itemType = "richtext"
case field.Type.Kind() == reflect.Bool:
itemType = "switch"
case field.Type.Kind() >= reflect.Int && field.Type.Kind() <= reflect.Int64:
itemType = "number"
case field.Type.Kind() >= reflect.Uint && field.Type.Kind() <= reflect.Uint64:
itemType = "number"
case field.Type.Kind() == reflect.Float32 || field.Type.Kind() == reflect.Float64:
itemType = "number"
default:
itemType = "string"
}
items = append(items, models.ManagedThemeConfigurationItem{
Key: key,
Name: field.Tag.Get("name"),
Required: field.Tag.Get("required") == "true",
Type: itemType,
Options: field.Tag.Get("options"),
Default: parseConfigurationDefault(field, field.Tag.Get("default")),
Help: field.Tag.Get("help"),
})
}
return models.Configuration{
Type: models.ThemeConfigurationManaged,
Name: name,
Data: items,
}
}
// DecodeConfiguration decodes resolved configuration values into a built-in
// channel's typed configuration struct.
func DecodeConfiguration(values map[string]any, target any) error {
raw, err := json.Marshal(values)
if err != nil {
return err
}
return json.Unmarshal(raw, target)
}
func parseConfigurationDefault(field reflect.StructField, raw string) any {
if raw == "" {
return nil
}
switch field.Type.Kind() {
case reflect.Bool:
value, err := strconv.ParseBool(raw)
if err == nil {
return value
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
value, err := strconv.ParseInt(raw, 10, field.Type.Bits())
if err == nil {
return value
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
value, err := strconv.ParseUint(raw, 10, field.Type.Bits())
if err == nil {
return value
}
case reflect.Float32, reflect.Float64:
value, err := strconv.ParseFloat(raw, field.Type.Bits())
if err == nil {
return value
}
}
return raw
}
-243
View File
@@ -1,243 +0,0 @@
package messageSender
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
type testChannel struct {
mu sync.Mutex
sends int
config map[string]any
unloaded bool
err error
}
func (c *testChannel) Send(_ context.Context, _ Notification, config map[string]any) error {
c.mu.Lock()
defer c.mu.Unlock()
c.sends++
c.config = config
return c.err
}
func (c *testChannel) Unload() error {
c.mu.Lock()
defer c.mu.Unlock()
c.unloaded = true
return nil
}
func TestRegistryRegisterListCallAndUnregister(t *testing.T) {
clearNotificationChannels()
t.Cleanup(clearNotificationChannels)
config := models.Configuration{
Type: models.ThemeConfigurationManaged,
Name: "Test",
Data: []models.ManagedThemeConfigurationItem{
{Key: "endpoint", Type: "string", Default: "default-endpoint"},
},
}
sender := &testChannel{}
if err := RegisterNotificationChannel("test", config, sender); err != nil {
t.Fatal(err)
}
if err := RegisterNotificationChannel("", config, sender); err == nil {
t.Fatal("empty id was accepted")
}
if err := RegisterNotificationChannel("test", config, sender); err == nil {
t.Fatal("duplicate id was accepted")
}
list := ListNotificationChannels()
if len(list) != 1 || list[0].ID != "test" || list[0].Configuration.Name != "Test" {
t.Fatalf("ListNotificationChannels() = %#v", list)
}
if err := CallNotificationChannel(context.Background(), "test", Notification{Title: "hello"}); err != nil {
t.Fatal(err)
}
if sender.config["endpoint"] != "default-endpoint" {
t.Fatalf("config = %#v", sender.config)
}
if err := UnregisterNotificationChannel("test"); err != nil {
t.Fatal(err)
}
if !sender.unloaded {
t.Fatal("Unload was not called")
}
if err := CallNotificationChannel(context.Background(), "test", Notification{}); err == nil {
t.Fatal("unregistered channel was callable")
}
}
func TestRegistryUnregisterWaitsForInFlightCall(t *testing.T) {
clearNotificationChannels()
t.Cleanup(clearNotificationChannels)
blocking := &blockingChannel{started: make(chan struct{}), release: make(chan struct{})}
if err := RegisterNotificationChannel("blocking", models.Configuration{Type: "managed"}, blocking); err != nil {
t.Fatal(err)
}
callDone := make(chan error, 1)
go func() { callDone <- CallNotificationChannel(context.Background(), "blocking", Notification{}) }()
<-blocking.started
unloadDone := make(chan error, 1)
go func() { unloadDone <- UnregisterNotificationChannel("blocking") }()
select {
case <-unloadDone:
t.Fatal("UnregisterNotificationChannel returned before the in-flight call finished")
case <-time.After(20 * time.Millisecond):
}
close(blocking.release)
if err := <-callDone; err != nil {
t.Fatal(err)
}
if err := <-unloadDone; err != nil {
t.Fatal(err)
}
}
type blockingChannel struct {
started chan struct{}
release chan struct{}
}
func (c *blockingChannel) Send(context.Context, Notification, map[string]any) error {
close(c.started)
<-c.release
return nil
}
func (c *blockingChannel) Unload() error { return nil }
func TestRegistryUsesSavedConfigurationAndResolvesSelectors(t *testing.T) {
clearNotificationChannels()
t.Cleanup(clearNotificationChannels)
db := dbcore.GetDBInstance()
if err := db.AutoMigrate(&models.MessageSenderProvider{}, &models.Client{}, &models.PingTask{}); err != nil {
t.Fatal(err)
}
const nodeID = "notification-registry-node"
const taskID = uint(43)
if err := db.Where("uuid = ?", nodeID).Delete(&models.Client{}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.Client{UUID: nodeID, Token: nodeID, Name: "node"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", taskID).Delete(&models.PingTask{}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.PingTask{Id: taskID, Name: "task"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Where("name = ?", "configured").Delete(&models.MessageSenderProvider{}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.MessageSenderProvider{
Name: "configured",
Addition: `{"nodes":"[\"notification-registry-node\",\"missing\"]","tasks":"[43,7]","token":"saved"}`,
}).Error; err != nil {
t.Fatal(err)
}
sender := &testChannel{}
config := models.Configuration{
Type: "managed",
Data: []models.ManagedThemeConfigurationItem{
{Key: "token", Type: "string", Default: "default"},
{Key: "nodes", Type: "nodes"},
{Key: "tasks", Type: "pingtasks"},
{Key: "missing", Type: "number"},
},
}
if err := RegisterNotificationChannel("configured", config, sender); err != nil {
t.Fatal(err)
}
if err := CallNotificationChannel(context.Background(), "configured", Notification{}); err != nil {
t.Fatal(err)
}
if sender.config["token"] != "saved" {
t.Fatalf("token = %#v", sender.config["token"])
}
if sender.config["missing"] != float64(0) {
t.Fatalf("missing default = %#v", sender.config["missing"])
}
nodes, ok := sender.config["nodes"].([]string)
if !ok || len(nodes) != 1 || nodes[0] != nodeID {
t.Fatalf("nodes = %#v", sender.config["nodes"])
}
tasks, ok := sender.config["tasks"].([]uint)
if !ok || len(tasks) != 1 || tasks[0] != taskID {
t.Fatalf("tasks = %#v", sender.config["tasks"])
}
}
func TestRegistryUsesLatestSavedConfiguration(t *testing.T) {
clearNotificationChannels()
t.Cleanup(clearNotificationChannels)
db := dbcore.GetDBInstance()
if err := db.AutoMigrate(&models.MessageSenderProvider{}); err != nil {
t.Fatal(err)
}
if err := db.Where("name = ?", "latest").Delete(&models.MessageSenderProvider{}).Error; err != nil {
t.Fatal(err)
}
sender := &testChannel{}
if err := RegisterNotificationChannel("latest", models.Configuration{Type: "managed"}, sender); err != nil {
t.Fatal(err)
}
if err := db.Create(&models.MessageSenderProvider{
Name: "latest",
Addition: `{"token":"first"}`,
}).Error; err != nil {
t.Fatal(err)
}
if err := CallNotificationChannel(context.Background(), "latest", Notification{}); err != nil {
t.Fatal(err)
}
if sender.config["token"] != "first" {
t.Fatalf("first config = %#v", sender.config)
}
if err := db.Model(&models.MessageSenderProvider{}).
Where("name = ?", "latest").
Update("addition", `{"token":"second"}`).Error; err != nil {
t.Fatal(err)
}
if err := CallNotificationChannel(context.Background(), "latest", Notification{}); err != nil {
t.Fatal(err)
}
if sender.config["token"] != "second" {
t.Fatalf("second config = %#v", sender.config)
}
}
func TestRegistryDoesNotRetryFailedCall(t *testing.T) {
clearNotificationChannels()
t.Cleanup(clearNotificationChannels)
sender := &testChannel{err: errors.New("send failed")}
if err := RegisterNotificationChannel("failed", models.Configuration{Type: "managed"}, sender); err != nil {
t.Fatal(err)
}
if err := CallNotificationChannel(context.Background(), "failed", Notification{}); err == nil {
t.Fatal("failed call returned nil")
}
if sender.sends != 1 {
t.Fatalf("sends = %d, want 1", sender.sends)
}
}
func clearNotificationChannels() {
notificationChannels.Lock()
notificationChannels.entries = make(map[string]*notificationChannelEntry)
notificationChannels.Unlock()
}
var _ NotificationChannel = (*testChannel)(nil)
var _ NotificationChannel = (*blockingChannel)(nil)
+118 -15
View File
@@ -1,18 +1,111 @@
package messageSender
import (
"context"
"encoding/json"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"reflect"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
var (
currentProvider factory.IMessageSender
mu = sync.Mutex{}
once = sync.Once{}
)
func CurrentProvider() factory.IMessageSender {
mu.Lock()
defer mu.Unlock()
return currentProvider
}
// Shutdown 销毁当前消息发送 provider,释放其持有的资源。供关闭流程调用。
func Shutdown() error {
mu.Lock()
defer mu.Unlock()
if currentProvider == nil {
return nil
}
err := currentProvider.Destroy()
currentProvider = nil
return err
}
func Initialize() {
go func() {
once.Do(func() {
all := factory.GetAllMessageSenders()
for _, provider := range all {
if _, err := database.GetMessageSenderConfigByName(provider.GetName()); err == nil {
continue
}
// 如果数据库中没有该提供者的配置,则保存默认配置
config := provider.GetConfiguration()
configBytes, err := json.Marshal(config)
if err != nil {
logger.Errorf("message-sender", "Failed to marshal config for provider %s: %v", provider.GetName(), err)
return
}
if err := database.SaveMessageSenderConfig(&models.MessageSenderProvider{
Name: provider.GetName(),
Addition: string(configBytes),
}); err != nil {
logger.Errorf("message-sender", "Failed to save default config for provider %s: %v", provider.GetName(), err)
return
}
}
})
}()
NotificationMethod, _ := config.GetAs[string](config.NotificationMethodKey, "none")
if NotificationMethod == "" || NotificationMethod == "none" {
LoadProvider("empty", "{}")
return
}
// 尝试从数据库加载配置
senderConfig, err := database.GetMessageSenderConfigByName(NotificationMethod)
if err != nil {
// 如果没有找到配置,使用empty provider
LoadProvider("empty", "{}")
return
}
LoadProvider(NotificationMethod, senderConfig.Addition)
}
func SendTextMessage(message string, title string) error {
if CurrentProvider() == nil {
return fmt.Errorf("message sender provider is not initialized")
}
var err error
NotificationEnabled, err := config.GetAs[bool](config.NotificationEnabledKey, false)
if err != nil {
return err
}
if !NotificationEnabled {
return nil
}
for i := 0; i < 3; i++ {
err = CurrentProvider().SendTextMessage(message, title)
if err == nil {
auditlog.Log("", "", "Message sent: "+title, "info")
return nil
}
}
auditlog.Log("", "", "Failed to send message after 3 attempts: "+err.Error()+","+title, "error")
return err
}
// SendNotification 是通知发送的统一实现:解析事件中的客户端 UUID(外部传入可只含
// UUID 字段)后委托 SendEvent。内部调用与 admin:sendNotification RPC 共用此实现。
func SendNotification(event models.EventMessage) error {
@@ -35,6 +128,9 @@ func SendNotification(event models.EventMessage) error {
}
func SendEvent(event models.EventMessage) error {
if CurrentProvider() == nil {
return fmt.Errorf("message sender provider is not initialized")
}
if event.Time.IsZero() {
event.Time = time.Now().UTC()
} else {
@@ -52,26 +148,33 @@ func SendEvent(event models.EventMessage) error {
return nil
}
method, err := config.GetAs[string](config.NotificationMethodKey, "none")
if err != nil {
// 检查提供者是否实现了 IEventMessageSender 接口
if eventSender, ok := CurrentProvider().(factory.IEventMessageSender); ok {
// 如果实现了,直接调用 SendEvent
for i := 0; i < 3; i++ {
err = eventSender.SendEvent(event)
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" {
auditlog.Log("", "", "Event message sent: "+fmt.Sprint(event.Event), "info")
return nil
}
}
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+fmt.Sprint(event.Event), "error")
return err
}
// 如果没有实现,使用模板格式化为文本消息
messageTemplate := cfg[config.NotificationTemplateKey].(string)
messageTemplate = parseTemplate(messageTemplate, event)
if method == "" || method == "none" {
return nil
for i := 0; i < 3; i++ {
err = CurrentProvider().SendTextMessage(messageTemplate, fmt.Sprint(event.Event))
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" { // QQ 会返回这个错误,但实际上消息是发送成功的
auditlog.Log("", "", "Event message sent: "+fmt.Sprint(event.Event), "info")
return nil
}
}
err = CallNotificationChannel(context.Background(), method, Notification{
Event: event,
Title: fmt.Sprint(event.Event),
Message: messageTemplate,
})
if err == nil {
auditlog.Log("", "", "Event message sent: "+fmt.Sprint(event.Event), "info")
return nil
}
auditlog.Log("", "", "Failed to send event message: "+err.Error()+","+fmt.Sprint(event.Event), "error")
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+fmt.Sprint(event.Event), "error")
return err
}
+21
View File
@@ -0,0 +1,21 @@
package serverchan3
import (
"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"`
}
// 注册 Server酱³ 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChan3Sender{}
})
}
@@ -0,0 +1,105 @@
package serverchan3
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// ServerChan3Sender 为 Server酱³ 推送实现
type ServerChan3Sender struct {
Addition
}
// GetName 返回推送通道名称
func (s *ServerChan3Sender) GetName() string {
return "Server酱³"
}
// GetConfiguration 返回配置结构体指针
func (s *ServerChan3Sender) GetConfiguration() factory.Configuration {
return &s.Addition
}
// Init 初始化(当前无需处理)
func (s *ServerChan3Sender) Init() error { return nil }
// Destroy 清理(当前无需处理)
func (s *ServerChan3Sender) Destroy() error { return nil }
// SendTextMessage 发送文本消息(携带标题)。
// 简化为固定 JSON POST:内部仅组装 {"title": ..., "desp": ...},不允许用户自定义请求体或内容类型。
func (s *ServerChan3Sender) SendTextMessage(message, title string) error {
apiURL := strings.TrimSpace(s.Addition.APIURL)
if apiURL == "" {
return fmt.Errorf("未配置 Server酱³ 接口地址(api_url)")
}
// 简化校验:若标题与正文均为空则拒绝发送
finalTitle := strings.TrimSpace(title)
finalMessage := strings.TrimSpace(message)
if finalTitle == "" && finalMessage == "" {
return fmt.Errorf("serverchan3: 标题与正文均为空")
}
// 固定 JSON 载荷,包含 title、desp,并支持可选 tags(通过 | 分割)
payload := map[string]string{
"title": finalTitle,
"desp": finalMessage,
}
if tagsStr := strings.TrimSpace(s.Addition.Tags); tagsStr != "" {
parts := strings.Split(tagsStr, "|")
var cleaned []string
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
cleaned = append(cleaned, t)
}
}
if len(cleaned) > 0 {
// Server酱³ 校验提示需要字符串,这里按 | 拼接为字符串
payload["tags"] = strings.Join(cleaned, "|")
}
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("组装 JSON 失败: %v", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 固定设置 Content-Type
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// 读取响应体用于错误信息打印
b, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(b))
if msg == "" {
return fmt.Errorf("接口返回非 2xx 状态码: %d", resp.StatusCode)
}
return fmt.Errorf("接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
}
return nil
}
// 之前的模板、表单解析与手动转义工具不再需要,简化实现
// 确保实现 IMessageSender 接口
var _ factory.IMessageSender = (*ServerChan3Sender)(nil)
@@ -0,0 +1,25 @@
package serverchanturbo
import (
"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,测试号用 , 分隔;企业微信应用用 | 分隔"`
}
// 注册 Server酱 Turbo 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChanTurboSender{}
})
}
@@ -0,0 +1,95 @@
package serverchanturbo
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// ServerChanTurboSender 为 Server酱 Turbo 推送实现
type ServerChanTurboSender struct {
Addition
}
// GetName 返回推送通道名称
func (s *ServerChanTurboSender) GetName() string {
return "Server酱Turbo"
}
// GetConfiguration 返回配置结构体指针
func (s *ServerChanTurboSender) GetConfiguration() factory.Configuration {
return &s.Addition
}
// Init 初始化(当前无需处理)
func (s *ServerChanTurboSender) Init() error { return nil }
// Destroy 清理(当前无需处理)
func (s *ServerChanTurboSender) Destroy() error { return nil }
// SendTextMessage 固定以 JSON 发送,仅组装 title 与 desp,支持可选 channel/noip/openid
func (s *ServerChanTurboSender) SendTextMessage(message, title string) error {
apiURL := strings.TrimSpace(s.Addition.APIURL)
if apiURL == "" {
return fmt.Errorf("未配置 Server酱 Turbo 接口地址(api_url)")
}
finalTitle := strings.TrimSpace(title)
finalMessage := strings.TrimSpace(message)
if finalTitle == "" && finalMessage == "" {
return fmt.Errorf("serverchanturbo: 标题与正文均为空")
}
payload := map[string]interface{}{
"title": finalTitle,
"desp": finalMessage,
}
if ch := strings.TrimSpace(s.Addition.Channel); ch != "" {
payload["channel"] = ch
}
if noip := strings.TrimSpace(s.Addition.NoIP); noip == "1" {
payload["noip"] = "1"
}
if openid := strings.TrimSpace(s.Addition.OpenID); openid != "" {
payload["openid"] = openid
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("serverchanturbo: 组装 JSON 失败: %v", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("serverchanturbo: 创建请求失败: %v", err)
}
// 使用 JSON 传递参数,需要设置 Content-Type
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("serverchanturbo: 发送请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(b))
if msg == "" {
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d", resp.StatusCode)
}
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
}
return nil
}
// 确保实现 IMessageSender 接口
var _ factory.IMessageSender = (*ServerChanTurboSender)(nil)
+8
View File
@@ -0,0 +1,8 @@
package telegram
type Addition struct {
BotToken string `json:"bot_token" required:"true"`
ChatID string `json:"chat_id" required:"true"`
MessageThreadID string `json:"message_thread_id" help:"Optional. Unique identifier of a message thread to which the message belongs; for supergroups only"`
Endpoint string `json:"endpoint" required:"true" default:"https://api.telegram.org/bot" help:"Telegram API endpoint, default is https://api.telegram.org/bot"`
}
+92
View File
@@ -0,0 +1,92 @@
package telegram
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type TelegramSender struct {
Addition
}
func (t *TelegramSender) GetName() string {
return "telegram"
}
func (t *TelegramSender) GetConfiguration() factory.Configuration {
return &t.Addition
}
func (t *TelegramSender) Init() error {
// 初始化逻辑,如果需要的话
return nil
}
func (t *TelegramSender) Destroy() error {
// 清理逻辑,如果需要的话
return nil
}
func (t *TelegramSender) SendTextMessage(message, title string) error {
fullMessage := message
if title != "" {
fullMessage = fmt.Sprintf("<b>%s</b>\n%s", title, message)
}
if fullMessage == "" {
return errors.New("message is empty")
}
endpoint := t.Addition.Endpoint + t.Addition.BotToken + "/sendMessage"
data := url.Values{}
data.Set("chat_id", t.Addition.ChatID)
data.Set("text", fullMessage)
data.Set("parse_mode", "HTML")
// Add message_thread_id if provided
if t.Addition.MessageThreadID != "" {
data.Set("message_thread_id", t.Addition.MessageThreadID)
}
resp, err := http.PostForm(endpoint, data)
if err != nil {
return fmt.Errorf("failed to send message: %v", err)
}
defer resp.Body.Close()
var result struct {
Ok bool `json:"ok"`
Description string `json:"description"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode response: %v", err)
}
if resp.StatusCode != http.StatusOK {
if result.Description != "" {
return fmt.Errorf("telegram API error: %s", result.Description)
}
return fmt.Errorf("telegram API returned non-OK status: %d", resp.StatusCode)
}
if !result.Ok {
return fmt.Errorf("telegram API error: %s", result.Description)
}
return nil
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &TelegramSender{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*TelegramSender)(nil)
+17 -7
View File
@@ -1,11 +1,21 @@
package webhook
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
URL string `json:"url" name:"Webhook URL" required:"true"`
Method string `json:"method" name:"HTTP Method" default:"GET" type:"option" options:"POST,GET"`
ContentType string `json:"content_type" name:"Content Type" default:"application/json"`
Headers string `json:"headers" name:"Headers" help:"HTTP headers in JSON format"`
Body string `json:"body" name:"Body" default:"{\"message\":\"{{message}}\",\"title\":\"{{title}}\"}"` // 默认使用message和title字段
Username string `json:"username" name:"Username"`
Password string `json:"password" name:"Password"`
URL string `json:"url" required:"true"`
Method string `json:"method" default:"GET" type:"option" options:"POST,GET"`
ContentType string `json:"content_type" default:"application/json"`
Headers string `json:"headers" help:"HTTP headers in JSON format"`
Body string `json:"body" default:"{\"message\":\"{{message}}\",\"title\":\"{{title}}\"}"` // 默认使用message和title字段
Username string `json:"username"`
Password string `json:"password"`
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &WebhookSender{}
})
}
+30 -29
View File
@@ -2,7 +2,6 @@ package webhook
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -11,7 +10,7 @@ import (
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/factory"
"github.com/komari-monitor/komari/utils/messageSender/outboundhttp"
)
@@ -19,20 +18,28 @@ type WebhookSender struct {
Addition
}
func (w *WebhookSender) Unload() error {
func (w *WebhookSender) GetName() string {
return "webhook"
}
func (w *WebhookSender) GetConfiguration() factory.Configuration {
return &w.Addition
}
func (w *WebhookSender) Init() error {
return nil
}
func (w *WebhookSender) Send(_ context.Context, notification messageSender.Notification, config map[string]any) error {
var addition Addition
if err := messageSender.DecodeConfiguration(config, &addition); err != nil {
return err
}
if addition.URL == "" {
func (w *WebhookSender) Destroy() error {
return nil
}
func (w *WebhookSender) SendTextMessage(message, title string) error {
if w.Addition.URL == "" {
return fmt.Errorf("webhook URL is not configured")
}
method := strings.ToUpper(addition.Method)
method := strings.ToUpper(w.Addition.Method)
if method == "" {
method = "GET" // 默认使用 GET
}
@@ -44,9 +51,9 @@ func (w *WebhookSender) Send(_ context.Context, notification messageSender.Notif
switch method {
case "POST":
req, err = w.createPOSTRequest(addition, notification.Message, notification.Title)
req, err = w.createPOSTRequest(message, title)
case "GET":
req, err = w.createGETRequest(addition, notification.Message, notification.Title)
req, err = w.createGETRequest(message, title)
default:
return fmt.Errorf("unsupported HTTP method: %s", method)
}
@@ -56,9 +63,9 @@ func (w *WebhookSender) Send(_ context.Context, notification messageSender.Notif
}
// 解析并设置自定义头部
if addition.Headers != "" {
if w.Addition.Headers != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(addition.Headers), &headers); err == nil {
if err := json.Unmarshal([]byte(w.Addition.Headers), &headers); err == nil {
for key, value := range headers {
req.Header.Set(key, value)
}
@@ -66,8 +73,8 @@ func (w *WebhookSender) Send(_ context.Context, notification messageSender.Notif
}
// 设置基本认证
if addition.Username != "" && addition.Password != "" {
req.SetBasicAuth(addition.Username, addition.Password)
if w.Addition.Username != "" && w.Addition.Password != "" {
req.SetBasicAuth(w.Addition.Username, w.Addition.Password)
}
resp, err := client.Do(req)
@@ -84,8 +91,8 @@ func (w *WebhookSender) Send(_ context.Context, notification messageSender.Notif
return nil
}
func (w *WebhookSender) createPOSTRequest(addition Addition, message, title string) (*http.Request, error) {
contentType := addition.ContentType
func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request, error) {
contentType := w.Addition.ContentType
if contentType == "" {
contentType = "application/json"
}
@@ -93,12 +100,12 @@ func (w *WebhookSender) createPOSTRequest(addition Addition, message, title stri
// 用户自定义模板,按 Content-Type 决定如何替换占位符
var body string
if isJSONContentType(contentType) {
body = w.replaceTemplateJSON(addition.Body, message, title)
body = w.replaceTemplateJSON(w.Addition.Body, message, title)
} else {
body = w.replaceTemplate(addition.Body, message, title)
body = w.replaceTemplate(w.Addition.Body, message, title)
}
req, err := http.NewRequest("POST", addition.URL, bytes.NewBufferString(body))
req, err := http.NewRequest("POST", w.Addition.URL, bytes.NewBufferString(body))
if err != nil {
return nil, err
}
@@ -107,8 +114,8 @@ func (w *WebhookSender) createPOSTRequest(addition Addition, message, title stri
return req, nil
}
func (w *WebhookSender) createGETRequest(addition Addition, message, title string) (*http.Request, error) {
URL := w.replaceTemplate(addition.URL, message, title)
func (w *WebhookSender) createGETRequest(message, title string) (*http.Request, error) {
URL := w.replaceTemplate(w.Addition.URL, message, title)
u, err := url.Parse(URL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %v", err)
@@ -122,12 +129,6 @@ func (w *WebhookSender) createGETRequest(addition Addition, message, title strin
return req, nil
}
func Register() error {
return messageSender.RegisterNotificationChannel("webhook", messageSender.ManagedConfiguration("Webhook", Addition{}), &WebhookSender{})
}
var _ messageSender.NotificationChannel = (*WebhookSender)(nil)
// replaceTemplate 替换模板中的 {{message}} 和 {{title}} 占位符(不做转义)
func (w *WebhookSender) replaceTemplate(template, message, title string) string {
result := template
+234
View File
@@ -0,0 +1,234 @@
package notifier
import (
"fmt"
"reflect"
"sync"
"time"
"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/database/records"
"github.com/komari-monitor/komari/internal/scheduler"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
)
// LoadNotificationService 管理定时器和任务
type LoadNotificationService struct {
mu sync.Mutex
tasks map[int][]models.LoadNotification
}
var LoadNotificationManager = &LoadNotificationService{
tasks: make(map[int][]models.LoadNotification),
}
// Reload 重载时间表
func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotification) error {
m.mu.Lock()
defer m.mu.Unlock()
scheduler.RemovePrefix("load-notification:")
m.tasks = make(map[int][]models.LoadNotification)
// 按Interval分组任务
taskGroups := make(map[int][]models.LoadNotification)
for _, task := range loadNotifications {
taskGroups[task.Interval] = append(taskGroups[task.Interval], task)
}
// 为每个唯一的Interval创建定时器
for interval, tasks := range taskGroups {
interval := interval
tasks := append([]models.LoadNotification(nil), tasks...)
m.tasks[interval] = tasks
if err := scheduler.AddFunc(fmt.Sprintf("load-notification:%d", interval), scheduler.Every(time.Duration(interval)*time.Minute), func() {
for _, task := range tasks {
go executeLoadNotificationTask(task)
}
}); err != nil {
return err
}
}
return nil
}
// executeLoadNotificationTask 执行单个LoadNotificationTask
func executeLoadNotificationTask(task models.LoadNotification) {
// 检查是否在冷却期内
if shouldSkipNotification(task) {
return
}
now := time.Now().UTC()
windowStart := now.Add(-time.Duration(task.Interval) * time.Minute)
overloadClients := make([]string, 0)
for _, clientUUID := range task.Clients {
// 仅查询当前通知使用的指标,避免重建完整监控记录。
records, err := getMetricRecordsForClient(clientUUID, task.Metric, windowStart, now)
if err != nil {
continue
}
// 检查指标是否达到阈值
if checkMetricThreshold(records, task) {
overloadClients = append(overloadClients, clientUUID)
}
}
sendLoadNotification(overloadClients, task)
updateLastNotified(task.Id, now)
}
// shouldSkipNotification 检查是否应该跳过通知(冷却期检查)
func shouldSkipNotification(task models.LoadNotification) bool {
if task.LastNotified == nil || task.LastNotified.IsZero() {
return false
}
// 计算冷却期(使用 interval 作为冷却期)
cooldownPeriod := time.Duration(task.Interval) * time.Minute
timeSinceLastNotified := time.Since(*task.LastNotified)
return timeSinceLastNotified < cooldownPeriod
}
// getMetricRecordsForClient 获取指定客户端在时间窗口内的单项指标最大值。
func getMetricRecordsForClient(clientUUID, metricName string, start, end time.Time) ([]models.Record, error) {
return records.GetRecordMetricMaxByClientAndTime(clientUUID, metricName, start, end)
}
// checkMetricThreshold 检查指标是否达到阈值
func checkMetricThreshold(records []models.Record, task models.LoadNotification) bool {
if len(records) == 0 {
return false
}
// 计算需要达标的最小记录数
minRequiredRecords := int(float32(len(records)) * task.Ratio)
if minRequiredRecords == 0 {
minRequiredRecords = 1
}
exceededCount := 0
for _, record := range records {
metricValue := getMetricValue(record, task.Metric)
if metricValue >= task.Threshold {
exceededCount++
}
}
return exceededCount >= minRequiredRecords
}
// getMetricValue 根据指标名称获取记录中的对应值
func getMetricValue(record models.Record, metric string) float32 {
switch metric {
case "cpu":
return record.Cpu
case "gpu":
return record.Gpu
case "net_in", "netin":
return bytesPerSecondToMbps(record.NetIn)
case "net_out", "netout":
return bytesPerSecondToMbps(record.NetOut)
case "ram":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
logger.Errorf("notifier", "Failed to get client info for %s: %v", record.Client, err)
return 0
}
if client.MemTotal > 0 {
return float32(record.Ram) / float32(client.MemTotal) * 100
}
return 0
case "swap":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
logger.Errorf("notifier", "Failed to get client info for %s: %v", record.Client, err)
return 0
}
if client.SwapTotal > 0 {
return float32(record.Swap) / float32(client.SwapTotal) * 100
}
return 0
case "load":
return record.Load
case "temp":
return record.Temp
case "disk":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
logger.Errorf("notifier", "Failed to get client info for %s: %v", record.Client, err)
return 0
}
if client.DiskTotal > 0 {
return float32(record.Disk) / float32(client.DiskTotal) * 100
}
return 0
default:
// 尝试通过反射获取字段值
v := reflect.ValueOf(record)
field := v.FieldByName(metric)
if field.IsValid() && field.CanInterface() {
switch field.Kind() {
case reflect.Float32:
return float32(field.Float())
case reflect.Float64:
return float32(field.Float())
case reflect.Int, reflect.Int32, reflect.Int64:
return float32(field.Int())
}
}
return 0
}
}
func bytesPerSecondToMbps(bytesPerSecond int64) float32 {
if bytesPerSecond <= 0 {
return 0
}
// 采用十进制 Mbps:1 Mbps = 1,000,000 bit/s
return float32(float64(bytesPerSecond) * 8 / 1_000_000)
}
// sendLoadNotification 发送负载通知
func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
if len(clientUUIDs) == 0 {
return
}
eventClients := make([]models.Client, 0, len(clientUUIDs))
for _, uuid := range clientUUIDs {
eventClients = append(eventClients, models.Client{UUID: uuid})
}
go func() {
if err := messageSender.SendNotification(models.EventMessage{
Event: messageevent.Alert,
Clients: eventClients,
Time: time.Now().UTC(),
Emoji: "⚠️",
Message: task.Name,
}); err != nil {
logger.Errorf("notifier", "Failed to send load notification for task %d: %v", task.Id, err)
}
}()
}
// updateLastNotified 更新最后通知时间
func updateLastNotified(taskId uint, notifyTime time.Time) {
db := dbcore.GetDBInstance()
if err := db.Model(&models.LoadNotification{}).Where("id = ?", taskId).Update("last_notified", notifyTime.UTC()).Error; err != nil {
logger.Errorf("notifier", "Failed to update last_notified for task %d: %v", taskId, err)
}
}
// ReloadLoadNotificationSchedule 加载或重载时间表
func ReloadLoadNotificationSchedule(loadNotifications []models.LoadNotification) error {
return LoadNotificationManager.Reload(loadNotifications)
}
-79
View File
@@ -1,79 +0,0 @@
package agent
import (
"context"
"errors"
"slices"
"sync"
"time"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
var (
ErrStartupConfigOffline = errors.New("agent is not connected")
ErrStartupConfigTimeout = errors.New("agent startup configuration timed out; the agent may need an update")
startupConfigMu sync.Mutex
startupConfigPending = make(map[string]startupConfigCall)
)
type startupConfigCall struct {
uuid string
response chan v2.StartupConfigResult
}
// GetStartupConfig holds credentials only for the lifetime of this request.
// The caller must require administrator access.
func GetStartupConfig(ctx context.Context, uuid string) (map[string]any, error) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
if err := ctx.Err(); err != nil {
return nil, err
}
if !slices.Contains(GetAllOnlineUUIDs(), uuid) {
return nil, ErrStartupConfigOffline
}
requestID := newV2EventID()
response := make(chan v2.StartupConfigResult, 1)
startupConfigMu.Lock()
startupConfigPending[requestID] = startupConfigCall{uuid: uuid, response: response}
startupConfigMu.Unlock()
defer func() {
startupConfigMu.Lock()
delete(startupConfigPending, requestID)
startupConfigMu.Unlock()
}()
if !DispatchV2Event(uuid, v2.MethodAgentStartupConfig, v2.StartupConfigParams{RequestID: requestID}) {
return nil, ErrStartupConfigOffline
}
select {
case result := <-response:
if result.Error != "" {
// Do not reflect an arbitrary agent message that might contain secrets.
return nil, errors.New("agent could not provide startup configuration")
}
if result.Config == nil {
return nil, errors.New("agent returned an empty startup configuration")
}
return result.Config, nil
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, ErrStartupConfigTimeout
}
return nil, ctx.Err()
}
}
// ResolveStartupConfig binds the response to the authenticated agent, never to
// an agent ID supplied in the response body.
func ResolveStartupConfig(uuid string, result v2.StartupConfigResult) bool {
startupConfigMu.Lock()
defer startupConfigMu.Unlock()
wait, ok := startupConfigPending[result.RequestID]
if !ok || wait.uuid != uuid {
return false
}
delete(startupConfigPending, result.RequestID)
wait.response <- result
return true
}
-33
View File
@@ -1,33 +0,0 @@
package agent
import (
"errors"
"slices"
"strings"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
var (
ErrSwitchVersionInvalidParams = errors.New("agent uuid and version are required")
ErrSwitchVersionOffline = errors.New("agent is offline")
ErrSwitchVersionUnsupported = errors.New("agent does not support v2 version switching")
)
func SwitchAgentVersion(uuid, version string) error {
uuid = strings.TrimSpace(uuid)
version = strings.TrimSpace(version)
if uuid == "" || version == "" {
return ErrSwitchVersionInvalidParams
}
if !slices.Contains(GetAllOnlineUUIDs(), uuid) {
return ErrSwitchVersionOffline
}
if !IsV2Client(uuid) {
return ErrSwitchVersionUnsupported
}
if !DispatchV2Event(uuid, v2.MethodAgentSwitchVersion, v2.SwitchVersionParams{Version: version}) {
return ErrSwitchVersionOffline
}
return nil
}
-2
View File
@@ -82,8 +82,6 @@ func EnqueueV2Event(uuid, method string, params any) v2.Event {
ttl = v2PingEventTTL
} else if method == v2.MethodAgentFile {
ttl = v2FileEventTTL
} else if method == v2.MethodAgentStartupConfig {
ttl = 20 * time.Second
}
event := v2.Event{
ID: newV2EventID(),
+2
View File
@@ -9,6 +9,8 @@ import (
)
const (
// 如果超过这个时间没有收到任何消息,则认为连接已死
readWait = 11 * time.Second
postPresenceTTL = 35 * time.Second
)
+2 -15
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
logger "github.com/komari-monitor/komari/utils/log"
"io"
"net"
"net/http"
"strings"
"time"
@@ -116,15 +115,6 @@ func handleV2RPC(uuid string, req v2.Request, allowWait bool) v2.Response {
return v2.Error(req.ID, -32004, "unknown or expired file operation", nil)
}
return v2.Success(req.ID, gin.H{"status": "success"})
case v2.MethodAgentStartupConfigResult:
var params v2.StartupConfigResult
if err := bindV2Params(req.Params, &params); err != nil {
return v2.Error(req.ID, -32602, "invalid startup configuration result", nil)
}
if !agent_runtime.ResolveStartupConfig(uuid, params) {
return v2.Error(req.ID, -32004, "unknown or expired startup configuration request", nil)
}
return v2.Success(req.ID, gin.H{"status": "success"})
default:
return v2.Error(req.ID, -32601, "method not found", req.Method)
}
@@ -184,15 +174,12 @@ func WebSocketV2RPC(c *gin.Context) {
if !pushQueuedV2Events(conn, uuid) {
return
}
setWebSocketPingHandler(conn, v2WebSocketReadWait, v2WebSocketWriteWait)
for {
conn.SetReadDeadline(time.Now().Add(v2WebSocketReadWait))
conn.SetReadDeadline(time.Now().Add(readWait))
_, message, err := conn.ReadMessage()
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
logger.Errorf("client-api", "Client %s v2 heartbeat timed out after %s", uuid, v2WebSocketReadWait)
} else if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
logger.Errorf("client-api", "Client %s v2 connection error: %v", uuid, err)
}
return
-26
View File
@@ -1,26 +0,0 @@
package client
import (
"time"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/web/connection"
)
const (
v2WebSocketReadWait = 75 * time.Second
v2WebSocketWriteWait = 10 * time.Second
)
func setWebSocketPingHandler(conn *connection.SafeConn, readWait, writeWait time.Duration) {
conn.SetPingHandler(func(data string) error {
if err := conn.SetReadDeadline(time.Now().Add(readWait)); err != nil {
return err
}
return conn.WriteControl(
websocket.PongMessage,
[]byte(data),
time.Now().Add(writeWait),
)
})
}
-165
View File
@@ -1,165 +0,0 @@
package client
import (
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/web/connection"
)
func newHeartbeatTestServer(
t *testing.T,
readWait time.Duration,
writeWait time.Duration,
ready chan<- struct{},
) (*httptest.Server, <-chan error) {
t.Helper()
serverDone := make(chan error, 1)
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawConn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
serverDone <- err
return
}
conn := connection.NewSafeConn(rawConn)
setWebSocketPingHandler(conn, readWait, writeWait)
if ready != nil {
close(ready)
}
defer func() {
_ = conn.Close()
serverDone <- err
}()
for {
if err = conn.SetReadDeadline(time.Now().Add(readWait)); err != nil {
return
}
_, _, err = conn.ReadMessage()
if err != nil {
return
}
}
}))
return server, serverDone
}
func dialHeartbeatTestServer(t *testing.T, server *httptest.Server) *websocket.Conn {
t.Helper()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
return conn
}
func TestWebSocketHeartbeatRefreshesDeadlineOnPing(t *testing.T) {
const (
pingPeriod = 40 * time.Millisecond
readWait = 300 * time.Millisecond
writeWait = 100 * time.Millisecond
)
ready := make(chan struct{})
server, serverDone := newHeartbeatTestServer(t, readWait, writeWait, ready)
defer server.Close()
clientConn := dialHeartbeatTestServer(t, server)
defer clientConn.Close()
pong := make(chan struct{}, 1)
clientConn.SetPongHandler(func(string) error {
select {
case pong <- struct{}{}:
default:
}
return nil
})
clientReadDone := make(chan struct{})
go func() {
defer close(clientReadDone)
for {
if _, _, err := clientConn.ReadMessage(); err != nil {
return
}
}
}()
select {
case <-ready:
case <-time.After(time.Second):
t.Fatal("server heartbeat handler did not start")
}
stopPings := make(chan struct{})
pingsDone := make(chan struct{})
go func() {
defer close(pingsDone)
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case <-stopPings:
return
case <-ticker.C:
if err := clientConn.WriteControl(
websocket.PingMessage,
nil,
time.Now().Add(writeWait),
); err != nil {
return
}
}
}
}()
select {
case <-pong:
case <-time.After(time.Second):
t.Fatal("server did not answer the client ping")
}
select {
case err := <-serverDone:
t.Fatalf("server closed a responsive idle connection: %v", err)
case <-time.After(4 * readWait):
}
close(stopPings)
<-pingsDone
_ = clientConn.Close()
<-clientReadDone
select {
case <-serverDone:
case <-time.After(time.Second):
t.Fatal("server did not finish after client close")
}
}
func TestWebSocketHeartbeatTimesOutWithoutPing(t *testing.T) {
const (
readWait = 300 * time.Millisecond
writeWait = 100 * time.Millisecond
)
server, serverDone := newHeartbeatTestServer(t, readWait, writeWait, nil)
defer server.Close()
clientConn := dialHeartbeatTestServer(t, server)
defer clientConn.Close()
select {
case err := <-serverDone:
netErr, ok := err.(net.Error)
if !ok || !netErr.Timeout() {
t.Fatalf("server read error = %v, want heartbeat timeout", err)
}
case <-time.After(2 * time.Second):
t.Fatal("server did not time out an idle peer")
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ func EstablishConnection(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
conn, err := api.UpgradeSafeConn(c, api.EnableWebSocketCompression)
conn, err := api.UpgradeSafeConn(c)
if err != nil {
closeSession(session_id)
return
+1 -1
View File
@@ -55,7 +55,7 @@ func RequestTerminal(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Require WebSocket upgrade"})
return
}
conn, err := api.UpgradeSafeConn(c, api.EnableWebSocketCompression)
conn, err := api.UpgradeSafeConn(c)
if err != nil {
return
}
+1 -1
View File
@@ -19,7 +19,7 @@ func GetClients(c *gin.Context) {
return
}
// Upgrade the HTTP connection to a WebSocket connection
conn, err := UpgradeSafeConn(c, EnableWebSocketCompression)
conn, err := UpgradeSafeConn(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
-17
View File
@@ -128,19 +128,6 @@ func (sc *SafeConn) WriteMessage(messageType int, data []byte) error {
return sc.writeFrame(messageType, data)
}
func (sc *SafeConn) WriteControl(messageType int, data []byte, deadline time.Time) error {
sc.mu.Lock()
defer sc.mu.Unlock()
if sc.interceptor == nil || sc.info == nil {
return sc.conn.WriteControl(messageType, data, deadline)
}
newType, newData, drop := sc.interceptor.OnSend(sc.info, messageType, data)
if drop {
return nil
}
return sc.conn.WriteControl(newType, newData, deadline)
}
// WriteJSON encodes v then sends it as one text frame through the wsSend
// hook chain.
func (sc *SafeConn) WriteJSON(v interface{}) error {
@@ -179,10 +166,6 @@ func (sc *SafeConn) SetReadDeadline(t time.Time) error {
return sc.conn.SetReadDeadline(t)
}
func (sc *SafeConn) SetPingHandler(h func(string) error) {
sc.conn.SetPingHandler(h)
}
func (sc *SafeConn) GetConn() *websocket.Conn {
sc.mu.Lock()
defer sc.mu.Unlock()
-57
View File
@@ -7,7 +7,6 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
@@ -132,62 +131,6 @@ func TestSafeConnInterceptorWired(t *testing.T) {
}
}
func TestSafeConnPingHandlerWritesPongThroughInterceptor(t *testing.T) {
pong := make(chan string, 1)
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.SetPongHandler(func(data string) error {
pong <- data
return nil
})
if err := conn.WriteControl(websocket.PingMessage, []byte("heartbeat"), time.Now().Add(time.Second)); err != nil {
return
}
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}))
defer server.Close()
sc := dialEcho(t, server)
defer sc.Close()
rec := &recordingInterceptor{}
sc.SetInterceptor(&ConnInfo{ID: sc.ID, Path: "/x"}, rec)
sc.SetPingHandler(func(data string) error {
return sc.WriteControl(websocket.PongMessage, []byte(data), time.Now().Add(time.Second))
})
if err := sc.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
readDone := make(chan error, 1)
go func() {
_, _, err := sc.ReadMessage()
readDone <- err
}()
select {
case data := <-pong:
if data != "heartbeat" {
t.Fatalf("pong data = %q, want heartbeat", data)
}
case <-time.After(time.Second):
t.Fatal("did not receive pong")
}
_, _, sends, _ := rec.calls()
if len(sends) != 1 || sends[0] != "heartbeat" {
t.Fatalf("interceptor sends = %v, want heartbeat pong", sends)
}
_ = sc.Close()
<-readDone
}
// TestSafeConnRewriteAndWriteJSON 验证帧改写(读侧)与 WriteJSON 走同一拦截通道。
func TestSafeConnRewriteAndWriteJSON(t *testing.T) {
server := wsEchoServer(t)
+25 -52
View File
@@ -5,52 +5,39 @@ import (
"bytes"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/klauspost/compress/zstd"
)
var defaultDistCacheDir = "./cache/dist"
var defaultDistFiles map[string][]byte
func PrepareDefaultDist() error {
targetDir, err := filepath.Abs(defaultDistCacheDir)
if err != nil {
return fmt.Errorf("resolve embedded dist cache: %w", err)
}
if err := extractDistArchive(embeddedDistArchive, targetDir); err != nil {
return err
}
defaultDistCacheDir = targetDir
return nil
func loadEmbeddedDist() (map[string][]byte, error) {
return decodeEmbeddedDist(embeddedDistArchive)
}
func extractDistArchive(archive []byte, targetDir string) error {
if err := os.RemoveAll(targetDir); err != nil {
return fmt.Errorf("clear embedded dist cache: %w", err)
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("create embedded dist cache: %w", err)
}
decoder, err := zstd.NewReader(bytes.NewReader(archive))
func decodeEmbeddedDist(archive []byte) (map[string][]byte, error) {
decoder, err := zstd.NewReader(nil)
if err != nil {
return fmt.Errorf("open zstd archive: %w", err)
return nil, fmt.Errorf("open zstd archive: %w", err)
}
defer decoder.Close()
reader := tar.NewReader(decoder)
copyBuffer := make([]byte, 32*1024)
files := make(map[string]struct{})
tarBytes, err := decoder.DecodeAll(archive, nil)
if err != nil {
return nil, fmt.Errorf("decode zstd archive: %w", err)
}
files := make(map[string][]byte)
reader := tar.NewReader(bytes.NewReader(tarBytes))
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
return nil, fmt.Errorf("read tar entry: %w", err)
}
name := path.Clean(strings.ReplaceAll(header.Name, "\\", "/"))
@@ -61,42 +48,28 @@ func extractDistArchive(archive []byte, targetDir string) error {
strings.HasPrefix(name, "../") ||
strings.HasPrefix(name, "/") ||
strings.ContainsRune(name, '\x00') {
return fmt.Errorf("invalid embedded tar path %q", name)
return nil, fmt.Errorf("invalid embedded tar path %q", name)
}
target := filepath.Join(targetDir, filepath.FromSlash(name))
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0755); err != nil {
return fmt.Errorf("create embedded tar directory %q: %w", name, err)
}
continue
case tar.TypeReg, tar.TypeRegA:
if _, exists := files[name]; exists {
return fmt.Errorf("duplicate tar entry %q", name)
}
files[name] = struct{}{}
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("create embedded tar parent for %q: %w", name, err)
}
file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
content, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("create embedded tar entry %q: %w", name, err)
return nil, fmt.Errorf("read tar entry %q: %w", name, err)
}
_, copyErr := io.CopyBuffer(file, reader, copyBuffer)
closeErr := file.Close()
if copyErr != nil {
return fmt.Errorf("extract embedded tar entry %q: %w", name, copyErr)
}
if closeErr != nil {
return fmt.Errorf("close embedded tar entry %q: %w", name, closeErr)
if _, exists := files[name]; exists {
return nil, fmt.Errorf("duplicate tar entry %q", name)
}
files[name] = content
default:
return fmt.Errorf("unsupported tar entry %q type %d", name, header.Typeflag)
return nil, fmt.Errorf("unsupported tar entry %q type %d", name, header.Typeflag)
}
}
if info, err := os.Stat(filepath.Join(targetDir, IndexFile)); err != nil || info.IsDir() {
return fmt.Errorf("tar archive does not contain %q", IndexFile)
if _, ok := files[IndexFile]; !ok {
return nil, fmt.Errorf("tar archive does not contain %q", IndexFile)
}
return nil
return files, nil
}
+7 -2
View File
@@ -36,6 +36,12 @@ const (
func init() {
_ = os.MkdirAll("./data/theme", 0755)
var err error
defaultDistFiles, err = loadEmbeddedDist()
if err != nil {
panic("load embedded default frontend: " + err.Error())
}
}
func normalizeHTMLLanguage(language string) string {
@@ -182,8 +188,7 @@ func static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc), force
}
if strings.HasPrefix(embedPath, DistDir+"/") {
content, err := os.ReadFile(filepath.Join(defaultDistCacheDir, filepath.FromSlash(strings.TrimPrefix(embedPath, DistDir+"/"))))
if err == nil {
if content, ok := defaultDistFiles[strings.TrimPrefix(embedPath, DistDir+"/")]; ok {
return content, mime.TypeByExtension(filepath.Ext(embedPath)), true
}
} else if content, err := fs.ReadFile(defaultThemeFS, embedPath); err == nil {
+1 -14
View File
@@ -80,26 +80,13 @@ func TestEmbeddedDistDoesNotEmbedRawFiles(t *testing.T) {
if _, err := PublicFS.ReadFile("defaultTheme/dist/index.html"); err == nil {
t.Fatal("PublicFS still embeds the raw frontend files")
}
targetDir := filepath.Join(t.TempDir(), "dist")
if err := extractDistArchive(embeddedDistArchive, targetDir); err != nil {
t.Fatalf("extract embedded dist: %v", err)
}
content, err := os.ReadFile(filepath.Join(targetDir, IndexFile))
if err != nil || len(content) == 0 {
if content, ok := defaultDistFiles[IndexFile]; !ok || len(content) == 0 {
t.Fatalf("embedded dist does not contain a non-empty %q", IndexFile)
}
}
func TestStaticRestrictedDoesNotServeCustomAssetOverride(t *testing.T) {
gin.SetMode(gin.TestMode)
oldDefaultDistCacheDir := defaultDistCacheDir
defaultDistCacheDir = filepath.Join(t.TempDir(), "dist")
if err := extractDistArchive(embeddedDistArchive, defaultDistCacheDir); err != nil {
t.Fatalf("extract embedded dist: %v", err)
}
t.Cleanup(func() {
defaultDistCacheDir = oldDefaultDistCacheDir
})
t.Chdir(t.TempDir())
assetPath := filepath.Join("data", "theme", "custom", "dist", "assets")
if err := os.MkdirAll(assetPath, 0o755); err != nil {
+22 -3
View File
@@ -155,8 +155,12 @@ func registerAdminRoutes(r *gin.Engine) {
{
settings.GET("/", jsonRpc.Bind("admin:getSettings"))
settings.POST("/", jsonRpc.Bind("admin:editSettings"))
settings.GET("/xtermjs", jsonRpc.Bind("admin:getXtermjsSettings"))
settings.POST("/xtermjs", jsonRpc.Bind("admin:setXtermjsSettings", jsonRpc.WithMessage("settings saved")))
settings.POST("/oidc", jsonRpc.Bind("admin:setOidcProvider"))
settings.GET("/oidc", jsonRpc.Bind("admin:getOidcProvider", jsonRpc.WithQuery("provider")))
settings.POST("/message-sender", jsonRpc.Bind("admin:setMessageSenderProvider"))
settings.GET("/message-sender", jsonRpc.Bind("admin:getMessageSenderProvider", jsonRpc.WithQuery("provider")))
}
// database storage inspection and maintenance
@@ -203,6 +207,17 @@ func registerAdminRoutes(r *gin.Engine) {
g.GET("/logs", jsonRpc.Bind("admin:getLogs", jsonRpc.WithQuery("limit", "page")))
// clipboard
clipboardGroup := g.Group("/clipboard")
{
clipboardGroup.GET("/:id", jsonRpc.Bind("admin:getClipboard", jsonRpc.WithPath("id")))
clipboardGroup.GET("", jsonRpc.Bind("admin:listClipboard"))
clipboardGroup.POST("", jsonRpc.Bind("admin:createClipboard"))
clipboardGroup.POST("/:id", jsonRpc.Bind("admin:updateClipboard", jsonRpc.WithPath("id")))
clipboardGroup.POST("/remove", jsonRpc.Bind("admin:batchDeleteClipboard"))
clipboardGroup.POST("/:id/remove", jsonRpc.Bind("admin:deleteClipboard", jsonRpc.WithPath("id")))
}
// plugins: 安装流程通过统一的分片上传接口,启停/列表/日志走 RPC2,市场对齐主题市场。
pluginGroup := g.Group("/plugin")
{
@@ -225,13 +240,17 @@ func registerAdminRoutes(r *gin.Engine) {
// notifications
notificationGroup := g.Group("/notification")
{
notificationGroup.GET("/channels", jsonRpc.Bind("admin:listNotificationChannels"))
notificationGroup.GET("/channel/configuration", jsonRpc.Bind("admin:getNotificationChannelConfiguration", jsonRpc.WithQuery("id")))
notificationGroup.POST("/channel/configuration", jsonRpc.Bind("admin:setNotificationChannelConfiguration"))
notificationGroup.GET("/offline", jsonRpc.Bind("admin:listOfflineNotifications"))
notificationGroup.POST("/offline/edit", jsonRpc.Bind("admin:editOfflineNotification"))
notificationGroup.POST("/offline/enable", jsonRpc.Bind("admin:enableOfflineNotification"))
notificationGroup.POST("/offline/disable", jsonRpc.Bind("admin:disableOfflineNotification"))
loadAlert := notificationGroup.Group("/load")
{
loadAlert.GET("/", jsonRpc.Bind("admin:getAllLoadNotifications"))
loadAlert.POST("/add", jsonRpc.Bind("admin:addLoadNotification"))
loadAlert.POST("/delete", jsonRpc.Bind("admin:deleteLoadNotification"))
loadAlert.POST("/edit", jsonRpc.Bind("admin:editLoadNotification"))
}
}
// ping tasks
+3 -2
View File
@@ -113,8 +113,8 @@ handler 退化为薄适配层:解析 gin 参数 → 调 RPC → 把响应映
| 命名空间 | 方法(文件) |
| --- | --- |
| `admin` | client CRUD、ping task、session/settings/weight、notification(load/offline/traffic)、provider(messageSender/oidc)、task 查询、system(logs/cloudflared/exec/test) |
| `public` | getMe、getNodesInformation、getPublicSettings、getVersion、getClientRecentRecords、getRecordsByUUID、getPingRecords、getPublicPingTasks |
| `admin` | client CRUD、ping task、session/settings/weight、notification(load/offline/traffic)、clipboard、provider(messageSender/oidc)、task 查询、system(logs/cloudflared/exec/test)、xtermjs |
| `public` | getMe、getNodesInformation、getPublicSettings、getVersion、getClientRecentRecords、getRecordsByUUID、getPingRecords、getPublicPingTasks、recordVisitorEvent |
| Agent v2 | `agent.report`、`agent.basicInfo`、`agent.pingResult`、`agent.taskResult`、`agent.pull`(`/api/clients/v2/rpc`) |
### 声明式路由桥 `Bind`
@@ -130,6 +130,7 @@ r.GET("/api/admin/client/:uuid", jsonRpc.Bind("admin:getClient", jsonRpc.WithPat
- 默认 `renderStandard` → `{status:"success", message, data}`(data 为空时省略,对齐 `api.Response`)。
- `WithFlat()` → 把 result(map) 平铺到顶层 + `{status:"success"}`(addClient/getClientToken/getSessions/provider set)。
- `WithRaw()` → 直接输出 result(agent 裸 JSON / me / listClients / getClient)。
- `WithMessage(msg)` → 成功带固定 message(xtermjs 保存)。
- 错误:统一 `{status:"error", message}` + JSON-RPC 错误码到 HTTP 码映射。
### 保留为 REST 的接口(不走 RPC 桥)
-80
View File
@@ -1,80 +0,0 @@
package jsonrpc
import (
"context"
"errors"
"strings"
"github.com/komari-monitor/komari/pkg/rpc"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
func init() {
RegisterWithGroupAndMeta("getAgentStartupConfig", rpc.RoleAdmin, adminGetAgentStartupConfig, &rpc.MethodMeta{
Name: "admin:getAgentStartupConfig",
Summary: "Get all effective agent startup settings as a flat object",
Description: "Includes credentials and default values without redaction. Requires an online updated agent.",
Params: []rpc.ParamMeta{{Name: "uuid", Type: "string", Description: "Agent UUID"}},
Returns: "Flat startup configuration object",
})
RegisterWithGroupAndMeta("switchAgentVersion", rpc.RoleAdmin, adminSwitchAgentVersion, &rpc.MethodMeta{
Name: "admin:switchAgentVersion",
Summary: "Request an agent version switch",
Description: "Queues a fire-and-forget version switch for one online v2 agent.",
Params: []rpc.ParamMeta{
{Name: "uuid", Type: "string", Required: true, Description: "Target agent UUID"},
{Name: "version", Type: "string", Required: true, Description: "latest, a version line such as 1.2.*, or an exact version"},
},
Returns: "{ queued: true }",
Example: map[string]any{"uuid": "agent-uuid", "version": "1.2.*"},
})
rpc.MarkSensitive("admin:getAgentStartupConfig")
}
func adminGetAgentStartupConfig(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
if err := req.BindParams(&params); err != nil || strings.TrimSpace(params.UUID) == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "uuid is required", nil)
}
result, err := agent_runtime.GetStartupConfig(ctx, params.UUID)
switch {
case err == nil:
return result, nil
case errors.Is(err, agent_runtime.ErrStartupConfigOffline):
return nil, rpc.MakeError(rpc.Unavailable, err.Error(), nil)
case errors.Is(err, agent_runtime.ErrStartupConfigTimeout):
return nil, rpc.MakeError(rpc.DeadlineExceeded, err.Error(), nil)
case errors.Is(err, context.Canceled):
return nil, rpc.MakeError(rpc.Cancelled, "request canceled", nil)
default:
return nil, rpc.MakeError(rpc.InternalError, "failed to get agent startup configuration", nil)
}
}
func adminSwitchAgentVersion(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
Version string `json:"version"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "uuid and version are required", nil)
}
if strings.TrimSpace(params.UUID) == "" || strings.TrimSpace(params.Version) == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "uuid and version are required", nil)
}
if err := agent_runtime.SwitchAgentVersion(params.UUID, params.Version); err != nil {
switch {
case errors.Is(err, agent_runtime.ErrSwitchVersionInvalidParams):
return nil, rpc.MakeError(rpc.InvalidParams, err.Error(), nil)
case errors.Is(err, agent_runtime.ErrSwitchVersionOffline):
return nil, rpc.MakeError(rpc.Unavailable, err.Error(), nil)
case errors.Is(err, agent_runtime.ErrSwitchVersionUnsupported):
return nil, rpc.MakeError(rpc.Unimplemented, err.Error(), nil)
default:
return nil, rpc.MakeError(rpc.InternalError, "failed to dispatch agent version switch", nil)
}
}
return map[string]any{"queued": true}, nil
}
+113
View File
@@ -0,0 +1,113 @@
package jsonrpc
import (
"context"
"strconv"
"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/pkg/rpc"
)
// admin.clipboard.go
// 剪贴板 RPC2 方法(admin 命名空间)。
func init() {
reg("getClipboard", adminGetClipboard, "Get a clipboard entry by id")
reg("listClipboard", adminListClipboard, "List clipboard entries")
reg("createClipboard", adminCreateClipboard, "Create a clipboard entry")
reg("updateClipboard", adminUpdateClipboard, "Update a clipboard entry")
reg("deleteClipboard", adminDeleteClipboard, "Delete a clipboard entry")
reg("batchDeleteClipboard", adminBatchDeleteClipboard, "Batch delete clipboard entries")
}
func adminGetClipboard(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
ID string `json:"id"`
}
req.BindParams(&params)
id, err := strconv.Atoi(params.ID)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid ID", nil)
}
cb, err := clipboardDB.GetClipboardByID(id)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get clipboard: "+err.Error(), nil)
}
return cb, nil
}
func adminListClipboard(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
list, err := clipboardDB.ListClipboard()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to list clipboard: "+err.Error(), nil)
}
return list, nil
}
func adminCreateClipboard(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var cb models.Clipboard
if err := req.BindParams(&cb); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request: "+err.Error(), nil)
}
if err := clipboardDB.CreateClipboard(&cb); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to create clipboard: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "create clipboard:"+strconv.Itoa(cb.Id), "info")
return cb, nil
}
func adminUpdateClipboard(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
// id 来自路径参数(bridge 注入),其余字段为更新内容。
fields := map[string]interface{}{}
if err := req.BindParams(&fields); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request: "+err.Error(), nil)
}
idStr, _ := fields["id"].(string)
id, err := strconv.Atoi(idStr)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid ID", nil)
}
delete(fields, "id") // 路径注入的 id 不作为更新字段
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to update clipboard: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "update clipboard:"+strconv.Itoa(id), "info")
return nil, nil
}
func adminDeleteClipboard(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
ID string `json:"id"`
}
req.BindParams(&params)
id, err := strconv.Atoi(params.ID)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid ID", nil)
}
if err := clipboardDB.DeleteClipboard(id); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to delete clipboard: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "delete clipboard:"+strconv.Itoa(id), "warn")
return nil, nil
}
func adminBatchDeleteClipboard(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
IDs []int `json:"ids"`
}
req.BindParams(&params)
if len(params.IDs) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "IDs cannot be empty", nil)
}
if err := clipboardDB.DeleteClipboardBatch(params.IDs); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to batch delete clipboard: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "batch delete clipboard: "+strconv.Itoa(len(params.IDs))+" items", "warn")
return nil, nil
}
-9
View File
@@ -50,11 +50,6 @@ func init() {
Summary: "Get all settings",
Returns: "object",
})
RegisterWithGroupAndMeta("getLastRunVersion", rpc.RoleAdmin, adminGetLastRunVersion, &rpc.MethodMeta{
Name: "admin:getLastRunVersion",
Summary: "Get the version recorded before this startup",
Returns: "{ last_run_version: string | null }",
})
RegisterWithGroupAndMeta("editSettings", rpc.RoleAdmin, adminEditSettings, &rpc.MethodMeta{
Name: "admin:editSettings",
Summary: "Update settings (partial)",
@@ -117,10 +112,6 @@ func adminGetSettings(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonR
return cst, nil
}
func adminGetLastRunVersion(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return map[string]any{"last_run_version": dbcore.LastRunVersion()}, nil
}
// metricStoreConfigKeys 是与 metrics 独立数据库及 rollup 策略相关、需要
// 触发连接测试 + 热重载的配置键。
//
-25
View File
@@ -1,36 +1,11 @@
package jsonrpc
import (
"context"
"encoding/json"
"testing"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/internal/metricstore"
)
func TestAdminGetLastRunVersion(t *testing.T) {
result, rpcErr := adminGetLastRunVersion(context.Background(), nil)
if rpcErr != nil {
t.Fatal(rpcErr)
}
data, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
var payload struct {
LastRunVersion *string `json:"last_run_version"`
}
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatal(err)
}
want := dbcore.LastRunVersion()
if (payload.LastRunVersion == nil) != (want == nil) ||
(want != nil && *payload.LastRunVersion != *want) {
t.Fatalf("unexpected last run version: %s", data)
}
}
func TestMetricKeysTouched(t *testing.T) {
if !metricKeysTouched(map[string]interface{}{metricstore.MetricDBDSNKey: "metrics.db"}) {
t.Fatal("metric database DSN must trigger metric store validation")
+68 -57
View File
@@ -2,24 +2,25 @@ package jsonrpc
import (
"context"
"encoding/json"
"fmt"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/pkg/rpc"
"github.com/komari-monitor/komari/utils/messageSender"
"gorm.io/gorm/clause"
)
// admin.notification.go
// 通知相关 RPC2 方法(admin 命名空间):离线通知、发送通知。
// 通知相关 RPC2 方法(admin 命名空间):负载告警、离线通知。
func init() {
reg("listNotificationChannels", adminListNotificationChannels, "List notification channels")
reg("getNotificationChannelConfiguration", adminGetNotificationChannelConfiguration, "Get notification channel configuration")
reg("setNotificationChannelConfiguration", adminSetNotificationChannelConfiguration, "Set notification channel configuration")
// load notifications
reg("addLoadNotification", adminAddLoadNotification, "Create a load notification")
reg("deleteLoadNotification", adminDeleteLoadNotification, "Delete load notifications by ids")
reg("editLoadNotification", adminEditLoadNotification, "Edit load notifications")
reg("getAllLoadNotifications", adminGetAllLoadNotifications, "List all load notifications")
// offline notifications
reg("listOfflineNotifications", adminListOfflineNotifications, "List offline notifications")
reg("editOfflineNotification", adminEditOfflineNotification, "Edit offline notifications")
@@ -29,57 +30,6 @@ func init() {
reg("sendNotification", adminSendNotification, "Send a notification")
}
func adminListNotificationChannels(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return messageSender.ListNotificationChannels(), nil
}
func adminGetNotificationChannelConfiguration(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
ID string `json:"id"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request data: "+err.Error(), nil)
}
configuration, values, exists, err := messageSender.GetNotificationChannelConfiguration(params.ID)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to load notification channel configuration: "+err.Error(), nil)
}
if !exists {
return nil, rpc.MakeError(rpc.NotFound, "Notification channel not found: "+params.ID, nil)
}
return map[string]any{
"configuration": configuration,
"data": values,
}, nil
}
func adminSetNotificationChannelConfiguration(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
ID string `json:"id"`
Data map[string]any `json:"data"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request data: "+err.Error(), nil)
}
if !messageSender.NotificationChannelRegistered(params.ID) {
return nil, rpc.MakeError(rpc.NotFound, "Notification channel not found: "+params.ID, nil)
}
if params.Data == nil {
params.Data = map[string]any{}
}
addition, err := json.Marshal(params.Data)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid notification channel configuration: "+err.Error(), nil)
}
if err := database.SaveMessageSenderConfig(&models.MessageSenderProvider{
Name: params.ID,
Addition: string(addition),
}); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save notification channel configuration: "+err.Error(), nil)
}
return nil, nil
}
// adminSendNotification 发送一条通知。仅供外部(插件/脚本)通过 RPC 调用,
// 内部通知逻辑(notifier/renewal/session 等)直接调用 messageSender.SendNotification。
func adminSendNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
@@ -103,6 +53,67 @@ func reg(name string, h rpc.Handler, summary string) {
RegisterWithGroupAndMeta(name, rpc.RoleAdmin, h, &rpc.MethodMeta{Name: "admin:" + name, Summary: summary})
}
func adminAddLoadNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Clients []string `json:"clients"`
Name string `json:"name"`
Metric string `json:"metric"`
Threshold float32 `json:"threshold"`
Ratio float32 `json:"ratio"`
Interval int `json:"interval"`
}
req.BindParams(&params)
if len(params.Clients) == 0 || params.Metric == "" || params.Threshold == 0 || params.Ratio == 0 || params.Interval == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "clients, metric, threshold, ratio and interval are required", nil)
}
if params.Interval > 4*60 || params.Interval <= 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "Interval must be between 1 and 240 minutes", nil)
}
if params.Ratio <= 0 || params.Ratio > 1 {
return nil, rpc.MakeError(rpc.InvalidParams, "Ratio must be between 0 and 1", nil)
}
taskID, err := notification.AddLoadNotification(params.Clients, params.Name, params.Metric, params.Threshold, params.Ratio, params.Interval)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return map[string]any{"task_id": taskID}, nil
}
func adminDeleteLoadNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
ID []uint `json:"id"`
}
req.BindParams(&params)
if len(params.ID) == 0 {
return nil, rpc.MakeError(rpc.InvalidParams, "id is required", nil)
}
if err := notification.DeleteLoadNotification(params.ID); err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return nil, nil
}
func adminEditLoadNotification(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Notifications []*models.LoadNotification `json:"notifications"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request data", nil)
}
if err := notification.EditLoadNotification(params.Notifications); err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return nil, nil
}
func adminGetAllLoadNotifications(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
list, err := notification.GetAllLoadNotifications()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, err.Error(), nil)
}
return list, nil
}
func adminListOfflineNotifications(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var notifications []models.OfflineNotification
if err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(&notifications).Error; err != nil {
+46
View File
@@ -7,6 +7,8 @@ import (
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/rpc"
"github.com/komari-monitor/komari/utils/messageSender"
msfactory "github.com/komari-monitor/komari/utils/messageSender/factory"
"github.com/komari-monitor/komari/web/oauth"
oauthfactory "github.com/komari-monitor/komari/web/oauth/factory"
)
@@ -15,10 +17,54 @@ import (
// 消息发送器与 OIDC 提供者配置 RPC2 方法(admin 命名空间)。
func init() {
reg("getMessageSenderProvider", adminGetMessageSender, "Get message sender provider config or templates")
reg("setMessageSenderProvider", adminSetMessageSender, "Set message sender provider config")
reg("getOidcProvider", adminGetOidc, "Get OIDC provider config or templates")
reg("setOidcProvider", adminSetOidc, "Set OIDC provider config")
}
func adminGetMessageSender(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Provider string `json:"provider"`
}
req.BindParams(&params)
if params.Provider != "" {
cfg, err := database.GetMessageSenderConfigByName(params.Provider)
if err != nil {
return nil, rpc.MakeError(rpc.NotFound, "Provider not found: "+err.Error(), nil)
}
return cfg, nil
}
providers := msfactory.GetSenderConfigs()
if len(providers) == 0 {
return nil, rpc.MakeError(rpc.NotFound, "No message sender providers found", nil)
}
return providers, nil
}
func adminSetMessageSender(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var senderConfig models.MessageSenderProvider
if err := req.BindParams(&senderConfig); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid configuration: "+err.Error(), nil)
}
if senderConfig.Name == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Provider name is required", nil)
}
if _, exists := msfactory.GetConstructor(senderConfig.Name); !exists {
return nil, rpc.MakeError(rpc.NotFound, "Provider not found: "+senderConfig.Name, nil)
}
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save message sender provider configuration: "+err.Error(), nil)
}
method, _ := config.GetAs[string](config.NotificationMethodKey, "none")
if method == senderConfig.Name { // 正在使用,重载
if err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to load message sender provider: "+err.Error(), nil)
}
}
return map[string]any{"message": "Message sender provider set successfully"}, nil
}
func adminGetOidc(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Provider string `json:"provider"`
+263
View File
@@ -0,0 +1,263 @@
package jsonrpc
import (
"context"
"errors"
"strings"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/rpc"
)
// admin.xtermjs.go
// xterm.js 终端设置 RPC2 方法(admin 命名空间),含类型定义与归一化逻辑。
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 init() {
reg("getXtermjsSettings", adminGetXtermjs, "Get xterm.js terminal settings")
RegisterWithGroupAndMeta("setXtermjsSettings", rpc.RoleAdmin, adminSetXtermjs, &rpc.MethodMeta{
Name: "admin:setXtermjsSettings",
Summary: "Set xterm.js terminal settings",
})
}
func adminGetXtermjs(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
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 {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get xtermjs settings: "+err.Error(), nil)
}
return normalized, nil
}
func adminSetXtermjs(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var settings XtermJSSettings
if err := req.BindParams(&settings); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing request body: "+err.Error(), nil)
}
normalized, err := normalizeXtermJSSettings(settings)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid xtermjs settings: "+err.Error(), nil)
}
if err := config.Set(config.XtermjsSettingsKey, normalized); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save settings: "+err.Error(), nil)
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, "update xtermjs settings", "info")
return normalized, nil
}
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 {
return strings.TrimSpace(value)
}
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
}
+101
View File
@@ -0,0 +1,101 @@
package jsonrpc
import (
"encoding/json"
"testing"
"github.com/komari-monitor/komari/pkg/rpc"
)
// xtermjs 设置归一化逻辑的单元测试(迁移自 web/api/admin/settings_xtermjs_test.go,
// 改为直接测试归一化函数,不依赖 HTTP 层)。
func TestNormalizeXtermJSDefaults(t *testing.T) {
// 空输入(TerminalOptions 为 nil)应回退到默认值的非 options 部分。
out, err := normalizeXtermJSSettings(XtermJSSettings{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TerminalOptions == nil {
t.Fatal("expected default TerminalOptions")
}
if out.TerminalOptions.FontFamily != defaultXtermJSFontFamily {
t.Errorf("font family = %q, want default", out.TerminalOptions.FontFamily)
}
if out.TerminalPadding == nil || *out.TerminalPadding != defaultXtermJSPadding {
t.Error("expected default padding")
}
}
func TestNormalizeXtermJSFallbacks(t *testing.T) {
neg := -1
in := XtermJSSettings{
TerminalOptions: &TerminalOptions{
FontFamily: " ", // 空白 -> 默认
FontSize: 0, // 非法 -> 默认
Scrollback: &neg, // 负数 -> 默认
Theme: &ThemeConfig{Foreground: " "}, // 全空 -> nil
},
TerminalPadding: &neg, // 负数 -> 保持默认
}
out, err := normalizeXtermJSSettings(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TerminalOptions.FontFamily != defaultXtermJSFontFamily {
t.Errorf("blank font family should fall back to default, got %q", out.TerminalOptions.FontFamily)
}
if out.TerminalOptions.FontSize != defaultXtermJSFontSize {
t.Errorf("invalid font size should fall back to default, got %d", out.TerminalOptions.FontSize)
}
if *out.TerminalOptions.Scrollback != defaultXtermJSScrollback {
t.Errorf("negative scrollback should fall back to default")
}
if out.TerminalOptions.Theme != nil {
t.Error("all-empty theme should normalize to nil")
}
if *out.TerminalPadding != defaultXtermJSPadding {
t.Error("negative padding should fall back to default")
}
}
func TestNormalizeXtermJSExtendedAnsiRejectsEmpty(t *testing.T) {
_, err := normalizeXtermJSSettings(XtermJSSettings{
TerminalOptions: &TerminalOptions{Theme: &ThemeConfig{ExtendedAnsi: []string{" "}}},
})
if err == nil {
t.Fatal("expected error for empty extendedAnsi entry")
}
}
func TestNormalizeXtermJSThemeRoundTrip(t *testing.T) {
in := XtermJSSettings{
TerminalOptions: &TerminalOptions{
FontFamily: "JetBrains Mono",
FontSize: 18,
Theme: &ThemeConfig{Foreground: "#ffffff", ExtendedAnsi: []string{"#111111"}},
},
}
out, err := normalizeXtermJSSettings(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TerminalOptions.Theme == nil || out.TerminalOptions.Theme.Foreground != "#ffffff" {
t.Error("theme foreground should survive normalization")
}
// 序列化不应包含未知字段(ThemeConfig 是白名单结构)。
b, _ := json.Marshal(out)
if len(b) == 0 {
t.Fatal("marshal failed")
}
}
func TestXtermjsPermission(t *testing.T) {
// xtermjs 方法属于 admin 命名空间。
if rpc.CheckPermission(rpc.RoleGuest, "admin:getXtermjsSettings") {
t.Error("guest must not access admin:getXtermjsSettings")
}
if !rpc.CheckPermission(rpc.RoleAdmin, "admin:setXtermjsSettings") {
t.Error("admin must access admin:setXtermjsSettings")
}
}
+1
View File
@@ -14,6 +14,7 @@ var privateSiteLoginWhitelist = map[string]bool{
"public:getMe": true,
"public:getPublicSettings": true,
"public:getVersion": true,
"public:recordVisitorEvent": true,
}
// Dispatch 是所有传输入口的统一分发点:私有站点检查 → 权限校验 → 执行方法。
+1
View File
@@ -24,6 +24,7 @@ func TestPrivateSiteLoginWhitelist(t *testing.T) {
"public:getMe",
"public:getPublicSettings",
"public:getVersion",
"public:recordVisitorEvent",
}
for _, m := range required {
if !privateSiteLoginWhitelist[m] {
+288
View File
@@ -0,0 +1,288 @@
package jsonrpc
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/rpc"
)
const (
visitorAuditMaxEventLen = 64
visitorAuditMaxPathLen = 512
visitorAuditMaxRouteLen = 128
visitorAuditMaxTargetLen = 128
visitorAuditMaxUserAgentLen = 512
visitorAuditMaxDetailLen = 2048
visitorAuditMaxMessageLen = 4096
visitorAuditRatePerMinute = 30
visitorAuditRateBurst = 10
visitorAuditRateMaxEntries = 10000
visitorAuditLimiterEntryTTL = 10 * time.Minute
visitorAuditCleanupInterval = time.Minute
visitorAuditMessagePrefix = "visitor event: "
visitorAuditUnknownIPKey = "<unknown>"
)
type visitorAuditRateState struct {
tokens float64
lastRefill time.Time
lastSeen time.Time
}
type visitorAuditRateLimiter struct {
mu sync.Mutex
entries map[string]*visitorAuditRateState
lastCleanup time.Time
}
var visitorAuditLimiter = newVisitorAuditRateLimiter()
type visitorAuditParams struct {
Event string `json:"event"`
Action string `json:"action"`
Operation string `json:"operation"`
Path string `json:"path"`
Route string `json:"route"`
Target string `json:"target"`
Detail map[string]any `json:"detail"`
}
type visitorAuditMessage struct {
Event string `json:"event"`
Path string `json:"path,omitempty"`
Route string `json:"route,omitempty"`
Target string `json:"target,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
}
func init() {
RegisterWithGroupAndMeta("recordVisitorEvent", "public", publicRecordVisitorEvent, &rpc.MethodMeta{
Name: "public:recordVisitorEvent",
Summary: "Record a frontend visitor audit event",
Params: []rpc.ParamMeta{
{Name: "event", Type: "string", Required: true, Description: "Short event name, such as page_view or node_open"},
{Name: "action", Type: "string", Description: "Alias of event"},
{Name: "operation", Type: "string", Description: "Alias of event"},
{Name: "path", Type: "string", Description: "Frontend path or URL path, without secrets"},
{Name: "route", Type: "string", Description: "Frontend route name"},
{Name: "target", Type: "string", Description: "Optional target identifier"},
{Name: "detail", Type: "object", Description: "Optional bounded metadata supplied by the frontend"},
},
Returns: "{ status: string }",
})
}
func publicRecordVisitorEvent(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
meta := rpc.MetaFromContext(ctx)
ip := ""
uuid := ""
userAgent := ""
if meta != nil {
ip = meta.RemoteIP
uuid = meta.UserUUID
userAgent = meta.UserAgent
}
if !visitorAuditLimiter.Allow(ip, time.Now()) {
return map[string]any{"status": "rate_limited"}, nil
}
enabled, err := config.GetAs[bool](config.VisitorAuditEnabledKey, false)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get visitor audit configuration", nil)
}
if !enabled {
return map[string]any{"status": "disabled"}, nil
}
var params visitorAuditParams
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid params: "+err.Error(), nil)
}
event := normalizeVisitorAuditEvent(firstNonEmpty(params.Event, params.Action, params.Operation))
if event == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "event is required", nil)
}
message, err := buildVisitorAuditMessage(visitorAuditMessage{
Event: event,
Path: strings.TrimSpace(params.Path),
Route: strings.TrimSpace(params.Route),
Target: strings.TrimSpace(params.Target),
UserAgent: strings.TrimSpace(userAgent),
Detail: params.Detail,
})
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid detail", nil)
}
auditlog.Log(ip, uuid, message, "visitor")
return map[string]any{"status": "success"}, nil
}
func newVisitorAuditRateLimiter() *visitorAuditRateLimiter {
return &visitorAuditRateLimiter{entries: make(map[string]*visitorAuditRateState)}
}
func (l *visitorAuditRateLimiter) Allow(ip string, now time.Time) bool {
key := strings.TrimSpace(ip)
if key == "" {
key = visitorAuditUnknownIPKey
}
l.mu.Lock()
defer l.mu.Unlock()
if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) >= visitorAuditCleanupInterval {
l.cleanupLocked(now)
}
state, ok := l.entries[key]
if !ok {
if len(l.entries) >= visitorAuditRateMaxEntries {
return false
}
l.entries[key] = &visitorAuditRateState{
tokens: visitorAuditRateBurst - 1,
lastRefill: now,
lastSeen: now,
}
return true
}
if now.After(state.lastRefill) {
elapsedMinutes := now.Sub(state.lastRefill).Minutes()
state.tokens = min(float64(visitorAuditRateBurst), state.tokens+elapsedMinutes*visitorAuditRatePerMinute)
state.lastRefill = now
}
state.lastSeen = now
if state.tokens < 1 {
return false
}
state.tokens--
return true
}
func (l *visitorAuditRateLimiter) cleanupLocked(now time.Time) {
for key, state := range l.entries {
if now.Sub(state.lastSeen) >= visitorAuditLimiterEntryTTL {
delete(l.entries, key)
}
}
l.lastCleanup = now
}
func normalizeVisitorAuditEvent(event string) string {
event = strings.TrimSpace(strings.ToLower(event))
if event == "" {
return ""
}
var builder strings.Builder
builder.Grow(len(event))
written := 0
for _, r := range event {
allowed := false
switch {
case r == '_' || r == '-' || r == ':' || r == '.':
allowed = true
case unicode.IsLetter(r) || unicode.IsDigit(r):
allowed = true
case unicode.IsSpace(r):
r = '_'
allowed = true
}
if !allowed {
continue
}
if written >= visitorAuditMaxEventLen {
break
}
builder.WriteRune(r)
written++
}
return builder.String()
}
func trimVisitorAuditDetail(detail map[string]any) map[string]any {
if len(detail) == 0 {
return nil
}
encoded, err := json.Marshal(detail)
if err != nil {
return nil
}
if len(encoded) <= visitorAuditMaxDetailLen {
return detail
}
return map[string]any{"truncated": true, "size": len(encoded)}
}
func buildVisitorAuditMessage(message visitorAuditMessage) (string, error) {
message.Event = truncateString(strings.TrimSpace(message.Event), visitorAuditMaxEventLen)
message.Path = truncateString(strings.TrimSpace(message.Path), visitorAuditMaxPathLen)
message.Route = truncateString(strings.TrimSpace(message.Route), visitorAuditMaxRouteLen)
message.Target = truncateString(strings.TrimSpace(message.Target), visitorAuditMaxTargetLen)
message.UserAgent = truncateString(strings.TrimSpace(message.UserAgent), visitorAuditMaxUserAgentLen)
message.Detail = trimVisitorAuditDetail(message.Detail)
detailReduced := false
for {
encoded, err := json.Marshal(message)
if err != nil {
return "", err
}
if len(visitorAuditMessagePrefix)+len(encoded) <= visitorAuditMaxMessageLen {
return visitorAuditMessagePrefix + string(encoded), nil
}
if message.Detail != nil && !detailReduced {
message.Detail = map[string]any{"truncated": true}
detailReduced = true
continue
}
if !shrinkLongestVisitorAuditField(&message) {
return "", errors.New("visitor audit message exceeds maximum length")
}
}
}
func shrinkLongestVisitorAuditField(message *visitorAuditMessage) bool {
fields := []*string{&message.Path, &message.UserAgent, &message.Route, &message.Target}
var longest *string
longestLen := 0
for _, field := range fields {
if size := utf8.RuneCountInString(*field); size > longestLen {
longest = field
longestLen = size
}
}
if longest == nil {
return false
}
*longest = truncateString(*longest, longestLen/2)
return true
}
func truncateString(value string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
if limit <= 3 {
return string(runes[:limit])
}
return string(runes[:limit-3]) + "..."
}
+136
View File
@@ -0,0 +1,136 @@
package jsonrpc
import (
"encoding/json"
"strconv"
"strings"
"testing"
"time"
"unicode/utf8"
)
func TestNormalizeVisitorAuditEvent(t *testing.T) {
cases := map[string]string{
"Page View": "page_view",
"node:open.detail": "node:open.detail",
"../../bad<script>": "....badscript",
"": "",
}
for input, want := range cases {
if got := normalizeVisitorAuditEvent(input); got != want {
t.Fatalf("normalizeVisitorAuditEvent(%q) = %q, want %q", input, got, want)
}
}
got := normalizeVisitorAuditEvent(strings.Repeat("访", visitorAuditMaxEventLen+10))
if utf8.RuneCountInString(got) != visitorAuditMaxEventLen || !utf8.ValidString(got) {
t.Fatalf("expected a valid %d-rune event, got %q", visitorAuditMaxEventLen, got)
}
}
func TestTrimVisitorAuditDetail(t *testing.T) {
small := map[string]any{"route": "/", "ok": true}
if got := trimVisitorAuditDetail(small); got == nil || got["route"] != "/" {
t.Fatalf("expected small detail to be kept, got %#v", got)
}
large := map[string]any{"data": make([]byte, visitorAuditMaxDetailLen+1)}
got := trimVisitorAuditDetail(large)
if got == nil || got["truncated"] != true {
t.Fatalf("expected large detail to be replaced by truncation marker, got %#v", got)
}
}
func TestBuildVisitorAuditMessage(t *testing.T) {
message, err := buildVisitorAuditMessage(visitorAuditMessage{
Event: "page_view",
Path: strings.Repeat("\x01中文", visitorAuditMaxPathLen),
Route: strings.Repeat("路由", visitorAuditMaxRouteLen),
Target: strings.Repeat("目标", visitorAuditMaxTargetLen),
UserAgent: strings.Repeat("浏览器", visitorAuditMaxUserAgentLen),
Detail: map[string]any{"data": strings.Repeat("详情", visitorAuditMaxDetailLen)},
})
if err != nil {
t.Fatalf("buildVisitorAuditMessage returned error: %v", err)
}
if message == "" || len(message) > visitorAuditMaxMessageLen || !utf8.ValidString(message) {
t.Fatalf("unexpected message length %d", len(message))
}
encoded, ok := strings.CutPrefix(message, visitorAuditMessagePrefix)
if !ok {
t.Fatalf("message missing prefix: %q", message)
}
var decoded visitorAuditMessage
if err := json.Unmarshal([]byte(encoded), &decoded); err != nil {
t.Fatalf("message JSON is invalid: %v", err)
}
if decoded.Event != "page_view" {
t.Fatalf("unexpected event after bounding message: %q", decoded.Event)
}
}
func TestBuildVisitorAuditMessageBoundsUserAgentByRunes(t *testing.T) {
message, err := buildVisitorAuditMessage(visitorAuditMessage{
Event: "page_view",
UserAgent: strings.Repeat("访", visitorAuditMaxUserAgentLen+20),
})
if err != nil {
t.Fatalf("buildVisitorAuditMessage returned error: %v", err)
}
encoded, _ := strings.CutPrefix(message, visitorAuditMessagePrefix)
var decoded visitorAuditMessage
if err := json.Unmarshal([]byte(encoded), &decoded); err != nil {
t.Fatalf("message JSON is invalid: %v", err)
}
if got := utf8.RuneCountInString(decoded.UserAgent); got != visitorAuditMaxUserAgentLen {
t.Fatalf("user agent length = %d, want %d", got, visitorAuditMaxUserAgentLen)
}
}
func TestTruncateStringPreservesUTF8(t *testing.T) {
got := truncateString("中文路径测试", 4)
if got != "中..." || !utf8.ValidString(got) {
t.Fatalf("truncateString returned %q", got)
}
}
func TestVisitorAuditRateLimiter(t *testing.T) {
limiter := newVisitorAuditRateLimiter()
now := time.Unix(1000, 0)
for i := 0; i < visitorAuditRateBurst; i++ {
if !limiter.Allow("192.0.2.1", now) {
t.Fatalf("request %d within burst was rejected", i+1)
}
}
if limiter.Allow("192.0.2.1", now) {
t.Fatal("request over burst was allowed")
}
if !limiter.Allow("192.0.2.1", now.Add(2*time.Second)) {
t.Fatal("token was not refilled at 30 requests per minute")
}
cleanupAt := now.Add(2*time.Second + visitorAuditLimiterEntryTTL)
if !limiter.Allow("192.0.2.2", cleanupAt) {
t.Fatal("request from a new IP was rejected")
}
if _, ok := limiter.entries["192.0.2.1"]; ok {
t.Fatal("stale limiter entry was not cleaned up")
}
}
func TestVisitorAuditRateLimiterCapsTrackedIPs(t *testing.T) {
limiter := newVisitorAuditRateLimiter()
now := time.Unix(1000, 0)
for i := 0; i < visitorAuditRateMaxEntries; i++ {
if !limiter.Allow(strconv.Itoa(i), now) {
t.Fatalf("IP %d was rejected before the limiter reached capacity", i)
}
}
if limiter.Allow("overflow", now) {
t.Fatal("new IP was allowed after the limiter reached capacity")
}
if !limiter.Allow("0", now) {
t.Fatal("tracked IP was rejected after the limiter reached capacity")
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ func headerOrQueryTwoFACode(c *gin.Context) string {
}
func serveWebSocket(c *gin.Context) {
conn, err := api.UpgradeSafeConn(c, api.EnableWebSocketCompression)
conn, err := api.UpgradeSafeConn(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return