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