Compare commits

..
17 Commits
86 changed files with 2019 additions and 2783 deletions

No files matched your search

+1 -3
View File
@@ -24,9 +24,7 @@ jobs:
TAG: ${{ github.event.release.tag_name }}
run: |
frontend_ref=""
if [[ "$TAG" == "1.5.1" || "$TAG" == "1.5.1-rc1" ]]; then
frontend_ref="1.5.0-fix1"
elif [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
if [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
frontend_ref="${BASH_REMATCH[1]}"
fi
echo "frontend_ref=$frontend_ref" >> "$GITHUB_OUTPUT"
+2 -1
View File
@@ -15,6 +15,7 @@ komari.db
/utils/geoip/data
.vscode/
/backup
/cache
# Test binary, built with `go test -c`
*.test
@@ -37,4 +38,4 @@ komari
/dist/
.codex
/web/filemanager/data
/web/filemanager/data
+1
View File
@@ -12,6 +12,7 @@ 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,6 +6,7 @@ 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"
@@ -32,6 +33,10 @@ 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
@@ -1,64 +0,0 @@
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
}
+15 -6
View File
@@ -186,6 +186,13 @@ 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 打开数据库
// 之前采集。
@@ -221,13 +228,17 @@ func resolveDatabaseFile() string {
//
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
func backupOnVersionUpgrade() {
if versionID == "" {
return
}
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
prevVersion = strings.TrimSpace(prevVersion)
versionRecorded := readErr == nil && prevVersion != ""
lastRunVersion = nil
if versionRecorded || dbFileExistedAtStartup {
lastRunVersion = &prevVersion
}
if versionID == "" {
return
}
// 版本未变化,无需备份。
if versionRecorded && prevVersion == versionID {
@@ -450,8 +461,6 @@ func doInitialize() error {
&models.User{},
&models.Client{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.PingTask{},
&models.OidcProvider{},
-13
View File
@@ -1,13 +0,0 @@
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,18 +12,6 @@ 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
@@ -1,63 +0,0 @@
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,7 +98,6 @@ 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,6 +1196,7 @@ 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,7 +16,6 @@ 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:""`
@@ -55,7 +54,6 @@ 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"
@@ -75,7 +73,6 @@ const (
LoginNotificationKey = "login_notification"
TrafficLimitPercentageKey = "traffic_limit_percentage"
UpdatedAtKey = "updated_at"
XtermjsSettingsKey = "xtermjs_settings"
ThemeMarketSourcesKey = "theme_market_sources"
PluginMarketSourcesKey = "plugin_market_sources"
)
+36 -14
View File
@@ -25,17 +25,24 @@ type reportTrafficValues struct {
timestamp time.Time
hasUp bool
totalUp int64
totalUpAt time.Time
hasDown bool
totalDown int64
totalDownAt time.Time
}
var reportTrafficStates sync.Map
var (
reportTrafficStates sync.Map
reportWriteMu sync.Mutex
)
const (
reportBatchInterval = 3 * time.Second
reportBatchQueueSize = 4096
reportBatchQueueSize = 512
pingBatchMaxRecords = 512
reportBatchWriteTimeout = 10 * time.Second
// Longer gaps have no reliable per-bucket traffic attribution.
maxTrafficReportGap = 15 * time.Minute
)
var (
@@ -342,6 +349,11 @@ 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")
@@ -361,55 +373,65 @@ func writeReportBatch(ctx context.Context, reports []v2.Report) ([]v2.Report, er
state.mu.Unlock()
}
if !values.initialized {
totalUp, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
totalUp, hasUp, err := s.LatestBefore(ctx, 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 = totalUp
values.totalUp = int64(totalUp.Value)
values.totalUpAt = totalUp.Timestamp
values.hasUp = hasUp
}
totalDown, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
totalDown, hasDown, err := s.LatestBefore(ctx, 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 = totalDown
values.totalDown = int64(totalDown.Value)
values.totalDownAt = totalDown.Timestamp
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 {
if values.hasUp && !values.totalUpAt.IsZero() && report.UpdatedAt.Sub(values.totalUpAt) <= maxTrafficReportGap {
trafficUp = TrafficCounterDelta(report.Network.TotalUp, values.totalUp)
}
trafficDown := int64(0)
if values.hasDown {
if values.hasDown && !values.totalDownAt.IsZero() && report.UpdatedAt.Sub(values.totalDownAt) <= maxTrafficReportGap {
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
}
// 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.
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.
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,6 +51,7 @@ 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 {
@@ -88,6 +89,9 @@ 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
}
@@ -166,6 +170,76 @@ 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,9 +112,6 @@ 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
}
@@ -175,14 +172,6 @@ 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,10 +35,7 @@ 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
@@ -0,0 +1,64 @@
// 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
@@ -0,0 +1,160 @@
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
}
+19 -11
View File
@@ -113,15 +113,16 @@ 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
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{}
}
// global is the process-wide plugin manager.
@@ -305,8 +306,8 @@ func (m *Manager) load(short string) error {
if rt.HasFunction("load") {
if err := rt.CallVoid("load"); err != nil {
rt.Close()
m.dropInstance(short, inst)
rt.Close()
return fmt.Errorf("plugin %q load() failed: %w", short, err)
}
}
@@ -328,11 +329,15 @@ 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)
@@ -385,6 +390,9 @@ 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,6 +18,7 @@ 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"
@@ -53,6 +54,8 @@ 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
@@ -222,6 +225,32 @@ 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() {
+7 -3
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,8 +18,12 @@ func (a *App) InitProviders() error {
go geoip.InitGeoIp()
a.addCleanup("geoip", func(context.Context) error { return geoip.Shutdown() })
messageSender.Initialize()
a.addCleanup("message-sender", func(context.Context) error { return messageSender.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()
})
return nil
}
-10
View File
@@ -14,7 +14,6 @@ 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"
@@ -23,7 +22,6 @@ 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"
@@ -76,11 +74,6 @@ 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) })
}
@@ -188,9 +181,6 @@ 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");
+4 -2
View File
@@ -4,15 +4,17 @@ 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, Fetch API,
// XMLHttpRequest, and queueMicrotask.
// JavaScript compatibility: injects bounded timer APIs, console, URL APIs,
// 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)
}
+56 -2
View File
@@ -22,6 +22,15 @@ 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 {
@@ -335,6 +344,41 @@ 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())
@@ -356,12 +400,18 @@ 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 closed.CompareAndSwap(false, true) {
if state.closed.CompareAndSwap(false, true) {
m.sockets.Delete(socket)
m.runtime.RemoveResource(resourceID)
_ = connection.Close()
if onClose != nil {
@@ -468,6 +518,7 @@ 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)
@@ -490,6 +541,9 @@ 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,6 +25,7 @@ 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)
+3 -1
View File
@@ -22,7 +22,6 @@ 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"
@@ -35,6 +34,7 @@ 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,6 +123,7 @@ type Runtime struct {
processModule *processmodule.Module
childProcessModule *childprocess.Module
netModule *netmodule.Module
tlsModule *tlsmodule.Module
httpModule *httpmodule.Module
cryptoModule *cryptomodule.Module
resourceMu sync.Mutex
@@ -237,6 +238,7 @@ 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,6 +29,8 @@ 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" &&
@@ -49,6 +51,25 @@ 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
@@ -0,0 +1,163 @@
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())
}
+5 -1
View File
@@ -30,7 +30,11 @@ var (
func getDigestEncoder() (*zstd.Encoder, error) {
digestEncoderOnce.Do(func() {
digestEncoder, digestEncoderErr = zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(1)))
digestEncoder, digestEncoderErr = zstd.NewWriter(nil,
zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(1)),
zstd.WithEncoderConcurrency(1),
zstd.WithLowerEncoderMem(true),
)
})
return digestEncoder, digestEncoderErr
}
+32 -13
View File
@@ -6,19 +6,22 @@ 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"
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"
)
type Request struct {
@@ -182,6 +185,22 @@ 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
@@ -1,15 +0,0 @@
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
@@ -1,118 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,289 +0,0 @@
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
@@ -1,22 +0,0 @@
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
@@ -1,43 +0,0 @@
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
@@ -1,60 +0,0 @@
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
@@ -1,21 +0,0 @@
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
+73 -80
View File
@@ -1,30 +1,80 @@
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/factory"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/outboundhttp"
)
// 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 {
Addition
mu sync.Mutex
script string
runtime *jsruntime.Runtime
}
func (j *JavaScriptSender) GetName() string {
return "Javascript"
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) GetConfiguration() factory.Configuration {
return &j.Addition
}
func (j *JavaScriptSender) Init() error {
runtime, err := jsruntime.New(j.Addition.Script, jsruntime.Options{})
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),
})
if err != nil {
return err
}
@@ -32,85 +82,28 @@ func (j *JavaScriptSender) Init() error {
runtime.Close()
return errors.New("sendMessage function not defined or not callable in script")
}
if j.runtime != nil {
j.runtime.Close()
}
j.script = script
j.runtime = runtime
return nil
}
func (j *JavaScriptSender) Destroy() error {
func (j *JavaScriptSender) closeRuntime() {
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 (j *JavaScriptSender) SendTextMessage(message, title string) error {
if err := j.ensureRuntime(); err != nil {
return err
}
return j.runtime.Call("sendMessage", message, title)
func Register() error {
return messageSender.RegisterNotificationChannel(ChannelID, messageSender.ManagedConfiguration("JavaScript", Addition{}), &JavaScriptSender{})
}
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)
var _ messageSender.NotificationChannel = (*JavaScriptSender)(nil)
+1 -1
View File
@@ -1,5 +1,5 @@
package javascript
type Addition struct {
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()."`
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."`
}
-29
View File
@@ -1,29 +0,0 @@
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
}
+19
View File
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,289 @@
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
@@ -0,0 +1,243 @@
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)
+15 -118
View File
@@ -1,111 +1,18 @@
package messageSender
import (
"encoding/json"
"context"
"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 {
@@ -128,9 +35,6 @@ 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 {
@@ -148,33 +52,26 @@ func SendEvent(event models.EventMessage) error {
return 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")
method, err := config.GetAs[string](config.NotificationMethodKey, "none")
if err != nil {
return err
}
// 如果没有实现,使用模板格式化为文本消息
messageTemplate := cfg[config.NotificationTemplateKey].(string)
messageTemplate = parseTemplate(messageTemplate, event)
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
}
if method == "" || method == "none" {
return nil
}
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+fmt.Sprint(event.Event), "error")
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")
return err
}
@@ -5,7 +5,6 @@ import (
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func TestParseTemplateFormatsEventTimeInLocalTimezone(t *testing.T) {
@@ -35,22 +34,3 @@ 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
}
}
-21
View File
@@ -1,21 +0,0 @@
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{}
})
}
@@ -1,105 +0,0 @@
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)
@@ -1,25 +0,0 @@
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{}
})
}
@@ -1,95 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,92 +0,0 @@
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)
+7 -17
View File
@@ -1,21 +1,11 @@
package webhook
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
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{}
})
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"`
}
+29 -30
View File
@@ -2,6 +2,7 @@ package webhook
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -10,7 +11,7 @@ import (
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/outboundhttp"
)
@@ -18,28 +19,20 @@ type WebhookSender struct {
Addition
}
func (w *WebhookSender) GetName() string {
return "webhook"
}
func (w *WebhookSender) GetConfiguration() factory.Configuration {
return &w.Addition
}
func (w *WebhookSender) Init() error {
func (w *WebhookSender) Unload() error {
return nil
}
func (w *WebhookSender) Destroy() error {
return nil
}
func (w *WebhookSender) SendTextMessage(message, title string) error {
if w.Addition.URL == "" {
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 == "" {
return fmt.Errorf("webhook URL is not configured")
}
method := strings.ToUpper(w.Addition.Method)
method := strings.ToUpper(addition.Method)
if method == "" {
method = "GET" // 默认使用 GET
}
@@ -51,9 +44,9 @@ func (w *WebhookSender) SendTextMessage(message, title string) error {
switch method {
case "POST":
req, err = w.createPOSTRequest(message, title)
req, err = w.createPOSTRequest(addition, notification.Message, notification.Title)
case "GET":
req, err = w.createGETRequest(message, title)
req, err = w.createGETRequest(addition, notification.Message, notification.Title)
default:
return fmt.Errorf("unsupported HTTP method: %s", method)
}
@@ -63,9 +56,9 @@ func (w *WebhookSender) SendTextMessage(message, title string) error {
}
// 解析并设置自定义头部
if w.Addition.Headers != "" {
if addition.Headers != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(w.Addition.Headers), &headers); err == nil {
if err := json.Unmarshal([]byte(addition.Headers), &headers); err == nil {
for key, value := range headers {
req.Header.Set(key, value)
}
@@ -73,8 +66,8 @@ func (w *WebhookSender) SendTextMessage(message, title string) error {
}
// 设置基本认证
if w.Addition.Username != "" && w.Addition.Password != "" {
req.SetBasicAuth(w.Addition.Username, w.Addition.Password)
if addition.Username != "" && addition.Password != "" {
req.SetBasicAuth(addition.Username, addition.Password)
}
resp, err := client.Do(req)
@@ -91,8 +84,8 @@ func (w *WebhookSender) SendTextMessage(message, title string) error {
return nil
}
func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request, error) {
contentType := w.Addition.ContentType
func (w *WebhookSender) createPOSTRequest(addition Addition, message, title string) (*http.Request, error) {
contentType := addition.ContentType
if contentType == "" {
contentType = "application/json"
}
@@ -100,12 +93,12 @@ func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request,
// 用户自定义模板,按 Content-Type 决定如何替换占位符
var body string
if isJSONContentType(contentType) {
body = w.replaceTemplateJSON(w.Addition.Body, message, title)
body = w.replaceTemplateJSON(addition.Body, message, title)
} else {
body = w.replaceTemplate(w.Addition.Body, message, title)
body = w.replaceTemplate(addition.Body, message, title)
}
req, err := http.NewRequest("POST", w.Addition.URL, bytes.NewBufferString(body))
req, err := http.NewRequest("POST", addition.URL, bytes.NewBufferString(body))
if err != nil {
return nil, err
}
@@ -114,8 +107,8 @@ func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request,
return req, nil
}
func (w *WebhookSender) createGETRequest(message, title string) (*http.Request, error) {
URL := w.replaceTemplate(w.Addition.URL, message, title)
func (w *WebhookSender) createGETRequest(addition Addition, message, title string) (*http.Request, error) {
URL := w.replaceTemplate(addition.URL, message, title)
u, err := url.Parse(URL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %v", err)
@@ -129,6 +122,12 @@ func (w *WebhookSender) createGETRequest(message, title string) (*http.Request,
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
@@ -1,234 +0,0 @@
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
@@ -0,0 +1,79 @@
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
@@ -0,0 +1,33 @@
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,6 +82,8 @@ 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,8 +9,6 @@ import (
)
const (
// 如果超过这个时间没有收到任何消息,则认为连接已死
readWait = 11 * time.Second
postPresenceTTL = 35 * time.Second
)
+15 -2
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
logger "github.com/komari-monitor/komari/utils/log"
"io"
"net"
"net/http"
"strings"
"time"
@@ -115,6 +116,15 @@ 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)
}
@@ -174,12 +184,15 @@ func WebSocketV2RPC(c *gin.Context) {
if !pushQueuedV2Events(conn, uuid) {
return
}
setWebSocketPingHandler(conn, v2WebSocketReadWait, v2WebSocketWriteWait)
for {
conn.SetReadDeadline(time.Now().Add(readWait))
conn.SetReadDeadline(time.Now().Add(v2WebSocketReadWait))
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
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) {
logger.Errorf("client-api", "Client %s v2 connection error: %v", uuid, err)
}
return
+26
View File
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,165 @@
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)
conn, err := api.UpgradeSafeConn(c, api.EnableWebSocketCompression)
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)
conn, err := api.UpgradeSafeConn(c, api.EnableWebSocketCompression)
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)
conn, err := UpgradeSafeConn(c, EnableWebSocketCompression)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
+17
View File
@@ -128,6 +128,19 @@ 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 {
@@ -166,6 +179,10 @@ 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,6 +7,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
@@ -131,6 +132,62 @@ 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)
+53 -26
View File
@@ -5,39 +5,52 @@ import (
"bytes"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/klauspost/compress/zstd"
)
var defaultDistFiles map[string][]byte
var defaultDistCacheDir = "./cache/dist"
func loadEmbeddedDist() (map[string][]byte, error) {
return decodeEmbeddedDist(embeddedDistArchive)
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 decodeEmbeddedDist(archive []byte) (map[string][]byte, error) {
decoder, err := zstd.NewReader(nil)
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))
if err != nil {
return nil, fmt.Errorf("open zstd archive: %w", err)
return fmt.Errorf("open zstd archive: %w", err)
}
defer decoder.Close()
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))
reader := tar.NewReader(decoder)
copyBuffer := make([]byte, 32*1024)
files := make(map[string]struct{})
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read tar entry: %w", err)
return fmt.Errorf("read tar entry: %w", err)
}
name := path.Clean(strings.ReplaceAll(header.Name, "\\", "/"))
@@ -48,28 +61,42 @@ func decodeEmbeddedDist(archive []byte) (map[string][]byte, error) {
strings.HasPrefix(name, "../") ||
strings.HasPrefix(name, "/") ||
strings.ContainsRune(name, '\x00') {
return nil, fmt.Errorf("invalid embedded tar path %q", name)
return fmt.Errorf("invalid embedded tar path %q", name)
}
target := filepath.Join(targetDir, filepath.FromSlash(name))
switch header.Typeflag {
case tar.TypeDir:
continue
if err := os.MkdirAll(target, 0755); err != nil {
return fmt.Errorf("create embedded tar directory %q: %w", name, err)
}
case tar.TypeReg, tar.TypeRegA:
content, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("read tar entry %q: %w", name, err)
}
if _, exists := files[name]; exists {
return nil, fmt.Errorf("duplicate tar entry %q", name)
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)
if err != nil {
return fmt.Errorf("create embedded 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)
}
files[name] = content
default:
return nil, fmt.Errorf("unsupported tar entry %q type %d", name, header.Typeflag)
return fmt.Errorf("unsupported tar entry %q type %d", name, header.Typeflag)
}
}
if _, ok := files[IndexFile]; !ok {
return nil, fmt.Errorf("tar archive does not contain %q", IndexFile)
if info, err := os.Stat(filepath.Join(targetDir, IndexFile)); err != nil || info.IsDir() {
return fmt.Errorf("tar archive does not contain %q", IndexFile)
}
return files, nil
return nil
}
+2 -7
View File
@@ -36,12 +36,6 @@ 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 {
@@ -188,7 +182,8 @@ func static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc), force
}
if strings.HasPrefix(embedPath, DistDir+"/") {
if content, ok := defaultDistFiles[strings.TrimPrefix(embedPath, DistDir+"/")]; ok {
content, err := os.ReadFile(filepath.Join(defaultDistCacheDir, filepath.FromSlash(strings.TrimPrefix(embedPath, DistDir+"/"))))
if err == nil {
return content, mime.TypeByExtension(filepath.Ext(embedPath)), true
}
} else if content, err := fs.ReadFile(defaultThemeFS, embedPath); err == nil {
+14 -1
View File
@@ -80,13 +80,26 @@ func TestEmbeddedDistDoesNotEmbedRawFiles(t *testing.T) {
if _, err := PublicFS.ReadFile("defaultTheme/dist/index.html"); err == nil {
t.Fatal("PublicFS still embeds the raw frontend files")
}
if content, ok := defaultDistFiles[IndexFile]; !ok || len(content) == 0 {
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 {
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 {
+3 -22
View File
@@ -155,12 +155,8 @@ 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
@@ -207,17 +203,6 @@ 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")
{
@@ -240,17 +225,13 @@ 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
+2 -3
View File
@@ -113,8 +113,8 @@ handler 退化为薄适配层:解析 gin 参数 → 调 RPC → 把响应映
| 命名空间 | 方法(文件) |
| --- | --- |
| `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 |
| `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 |
| Agent v2 | `agent.report`、`agent.basicInfo`、`agent.pingResult`、`agent.taskResult`、`agent.pull`(`/api/clients/v2/rpc`) |
### 声明式路由桥 `Bind`
@@ -130,7 +130,6 @@ 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
@@ -0,0 +1,80 @@
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
@@ -1,113 +0,0 @@
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,6 +50,11 @@ 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)",
@@ -112,6 +117,10 @@ 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,11 +1,36 @@
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")
+57 -68
View File
@@ -2,25 +2,24 @@ 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() {
// 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")
reg("listNotificationChannels", adminListNotificationChannels, "List notification channels")
reg("getNotificationChannelConfiguration", adminGetNotificationChannelConfiguration, "Get notification channel configuration")
reg("setNotificationChannelConfiguration", adminSetNotificationChannelConfiguration, "Set notification channel configuration")
// offline notifications
reg("listOfflineNotifications", adminListOfflineNotifications, "List offline notifications")
reg("editOfflineNotification", adminEditOfflineNotification, "Edit offline notifications")
@@ -30,6 +29,57 @@ 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) {
@@ -53,67 +103,6 @@ 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,8 +7,6 @@ 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"
)
@@ -17,54 +15,10 @@ 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
@@ -1,263 +0,0 @@
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
@@ -1,101 +0,0 @@
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,7 +14,6 @@ var privateSiteLoginWhitelist = map[string]bool{
"public:getMe": true,
"public:getPublicSettings": true,
"public:getVersion": true,
"public:recordVisitorEvent": true,
}
// Dispatch 是所有传输入口的统一分发点:私有站点检查 → 权限校验 → 执行方法。
-1
View File
@@ -24,7 +24,6 @@ func TestPrivateSiteLoginWhitelist(t *testing.T) {
"public:getMe",
"public:getPublicSettings",
"public:getVersion",
"public:recordVisitorEvent",
}
for _, m := range required {
if !privateSiteLoginWhitelist[m] {
-288
View File
@@ -1,288 +0,0 @@
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
@@ -1,136 +0,0 @@
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)
conn, err := api.UpgradeSafeConn(c, api.EnableWebSocketCompression)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return