refactor: remove v1 protocol and stabilize file transfer

This commit is contained in:
Akizon77 committed 2026-08-29 19:03:53 +08:00
1 parent 46fd2af44c
commit 2ff7c11db0
27 files changed
+344 -611

No files matched your search

+2 -2
View File
@@ -6,7 +6,7 @@ import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
v1 "github.com/komari-monitor/komari/protocol/v1"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
@@ -20,7 +20,7 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
}
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report v1.Report) error {
func ReportVerify(report v2.Report) error {
// 防止输入不合理范围
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
return fmt.Errorf("CPU.Usage must be between 0 and 100")
+7 -7
View File
@@ -9,7 +9,7 @@ import (
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
v1 "github.com/komari-monitor/komari/protocol/v1"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
func TestDefaultRollupPolicy(t *testing.T) {
@@ -572,15 +572,15 @@ func TestGetRecordsByClientAndTimeReadsRollupsAfterRawCompaction(t *testing.T) {
Connections: 321,
ConnectionsUdp: 12,
}
if _, err := WriteReport(ctx, v1.Report{
if _, err := WriteReport(ctx, v2.Report{
UUID: rec.Client,
UpdatedAt: ts,
CPU: v1.CPUReport{Usage: float64(rec.Cpu)},
Ram: v1.RamReport{Used: rec.Ram, Total: rec.RamTotal},
Load: v1.LoadReport{Load1: float64(rec.Load)},
Disk: v1.DiskReport{Used: rec.Disk, Total: rec.DiskTotal},
CPU: v2.CPUReport{Usage: float64(rec.Cpu)},
Ram: v2.RamReport{Used: rec.Ram, Total: rec.RamTotal},
Load: v2.LoadReport{Load1: float64(rec.Load)},
Disk: v2.DiskReport{Used: rec.Disk, Total: rec.DiskTotal},
Process: rec.Process,
Connections: v1.ConnectionsReport{
Connections: v2.ConnectionsReport{
TCP: rec.Connections,
UDP: rec.ConnectionsUdp,
},
+17 -17
View File
@@ -11,7 +11,7 @@ import (
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
v1 "github.com/komari-monitor/komari/protocol/v1"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
type reportTrafficState struct {
@@ -59,7 +59,7 @@ type reportBatchRequest struct {
type reportBatchWorker struct {
mu sync.Mutex
queue chan v1.Report
queue chan v2.Report
pingQueue chan models.PingRecord
requests chan reportBatchRequest
done chan struct{}
@@ -75,7 +75,7 @@ func StartReportBatcher() {
return
}
worker := &reportBatchWorker{
queue: make(chan v1.Report, reportBatchQueueSize),
queue: make(chan v2.Report, reportBatchQueueSize),
pingQueue: make(chan models.PingRecord, reportBatchQueueSize),
requests: make(chan reportBatchRequest, 1),
done: make(chan struct{}),
@@ -152,16 +152,16 @@ func FlushReportBatch(ctx context.Context) error {
// WriteReport persists one agent report and adds it to in-memory minute
// summaries using the same server receive time. Traffic deltas remain summable
// after rollup.
func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
func WriteReport(ctx context.Context, report v2.Report) (v2.Report, error) {
if report.UUID == "" {
return v1.Report{}, fmt.Errorf("report UUID is required")
return v2.Report{}, fmt.Errorf("report UUID is required")
}
if report.UpdatedAt.IsZero() {
return v1.Report{}, fmt.Errorf("report receive time is required")
return v2.Report{}, fmt.Errorf("report receive time is required")
}
report.UpdatedAt = report.UpdatedAt.UTC()
if GetStore() == nil {
return v1.Report{}, fmt.Errorf("metric store not enabled")
return v2.Report{}, fmt.Errorf("metric store not enabled")
}
reportBatcherMu.Lock()
@@ -169,19 +169,19 @@ func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
reportBatcherMu.Unlock()
if worker != nil {
if err := worker.enqueue(ctx, report); err != nil {
return v1.Report{}, err
return v2.Report{}, err
}
return report, nil
}
saved, err := writeReportBatch(ctx, []v1.Report{report})
saved, err := writeReportBatch(ctx, []v2.Report{report})
if err != nil {
return v1.Report{}, err
return v2.Report{}, err
}
return saved[0], nil
}
func (w *reportBatchWorker) enqueue(ctx context.Context, report v1.Report) error {
func (w *reportBatchWorker) enqueue(ctx context.Context, report v2.Report) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.stopping {
@@ -202,7 +202,7 @@ func (w *reportBatchWorker) run() {
ticker := time.NewTicker(reportBatchInterval)
defer ticker.Stop()
var pending []v1.Report
var pending []v2.Report
var pendingPings []models.PingRecord
for {
select {
@@ -252,7 +252,7 @@ func (w *reportBatchWorker) enqueuePing(ctx context.Context, record models.PingR
}
}
func drainReportQueue(queue <-chan v1.Report, limit int) []v1.Report {
func drainReportQueue(queue <-chan v2.Report, limit int) []v2.Report {
if limit <= 0 {
return nil
}
@@ -263,7 +263,7 @@ func drainReportQueue(queue <-chan v1.Report, limit int) []v1.Report {
if capacity == 0 {
return nil
}
reports := make([]v1.Report, 0, capacity)
reports := make([]v2.Report, 0, capacity)
for len(reports) < limit {
select {
case report := <-queue:
@@ -298,7 +298,7 @@ func drainPingQueue(queue <-chan models.PingRecord, limit int) []models.PingReco
return records
}
func writePendingReports(ctx context.Context, pending *[]v1.Report) error {
func writePendingReports(ctx context.Context, pending *[]v2.Report) error {
if len(*pending) == 0 {
return nil
}
@@ -335,7 +335,7 @@ func writePendingPingRecords(ctx context.Context, pending *[]models.PingRecord)
return nil
}
func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, error) {
func writeReportBatch(ctx context.Context, reports []v2.Report) ([]v2.Report, error) {
if len(reports) == 0 {
return nil, nil
}
@@ -349,7 +349,7 @@ func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, er
return nil, fmt.Errorf("metric store not enabled")
}
prepared := make([]v1.Report, len(reports))
prepared := make([]v2.Report, len(reports))
copy(prepared, reports)
points := make([]metric.Point, 0, len(reports)*20)
pendingStates := make(map[*reportTrafficState]reportTrafficValues)
+2 -2
View File
@@ -8,10 +8,10 @@ import (
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
v1 "github.com/komari-monitor/komari/protocol/v1"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
func reportMetricPoints(report v1.Report, trafficUp, trafficDown int64) []metric.Point {
func reportMetricPoints(report v2.Report, trafficUp, trafficDown int64) []metric.Point {
entityID := report.UUID
ts := report.UpdatedAt
points := []metric.Point{
+28 -28
View File
@@ -9,7 +9,7 @@ import (
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
v1 "github.com/komari-monitor/komari/protocol/v1"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
func useReportTestStore(t *testing.T, policy *metric.RollupPolicy) *metric.Store {
@@ -51,20 +51,20 @@ func TestWriteReportStoresMinuteMetricsAndResetAwareTraffic(t *testing.T) {
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
now := base.Add(45 * time.Second)
report := v1.Report{
report := v2.Report{
UUID: "node-a",
UpdatedAt: base,
CPU: v1.CPUReport{Usage: 12.5},
Ram: v1.RamReport{Used: 100, Total: 1000},
Swap: v1.RamReport{Used: 20, Total: 200},
Load: v1.LoadReport{Load1: 0.5},
Disk: v1.DiskReport{Used: 300, Total: 3000},
Network: v1.NetworkReport{Up: 3, Down: 4, TotalUp: 100, TotalDown: 200},
CPU: v2.CPUReport{Usage: 12.5},
Ram: v2.RamReport{Used: 100, Total: 1000},
Swap: v2.RamReport{Used: 20, Total: 200},
Load: v2.LoadReport{Load1: 0.5},
Disk: v2.DiskReport{Used: 300, Total: 3000},
Network: v2.NetworkReport{Up: 3, Down: 4, TotalUp: 100, TotalDown: 200},
Process: 7,
Connections: v1.ConnectionsReport{TCP: 8, UDP: 9},
GPU: &v1.GPUDetailReport{
Connections: v2.ConnectionsReport{TCP: 8, UDP: 9},
GPU: &v2.GPUDetailReport{
AverageUsage: 25,
DetailedInfo: []v1.GPUDeviceInfo{{
DetailedInfo: []v2.GPUDeviceInfo{{
Name: "GPU 0", MemoryUsed: 400, MemoryTotal: 800, Utilization: 30, Temperature: 55,
}},
},
@@ -126,7 +126,7 @@ func TestWriteReportSkipsMetricsWithoutAgentData(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
timestamp := time.Now().UTC()
if _, err := WriteReport(ctx, v1.Report{
if _, err := WriteReport(ctx, v2.Report{
UUID: "node-without-gpu", UpdatedAt: timestamp,
}); err != nil {
t.Fatalf("write report: %v", err)
@@ -154,11 +154,11 @@ func TestReportBatcherFlushesQueuedReports(t *testing.T) {
})
base := time.Now().UTC().Truncate(time.Minute).Add(10 * time.Second)
first := v1.Report{
first := v2.Report{
UUID: "batched-node",
UpdatedAt: base,
CPU: v1.CPUReport{Usage: 10},
Network: v1.NetworkReport{TotalUp: 100, TotalDown: 200},
CPU: v2.CPUReport{Usage: 10},
Network: v2.NetworkReport{TotalUp: 100, TotalDown: 200},
}
second := first
second.UpdatedAt = base.Add(3 * time.Second)
@@ -271,11 +271,11 @@ func TestReportBatchKeepsEverySample(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
base := time.Now().UTC().Truncate(time.Second)
pending := []v1.Report{
{UUID: "node-a", UpdatedAt: base, CPU: v1.CPUReport{Usage: 10}, Network: v1.NetworkReport{TotalUp: 100}},
{UUID: "node-a", UpdatedAt: base, CPU: v1.CPUReport{Usage: 20}, Network: v1.NetworkReport{TotalUp: 150}},
{UUID: "node-b", UpdatedAt: base, CPU: v1.CPUReport{Usage: 30}, Network: v1.NetworkReport{TotalUp: 200}},
{UUID: "node-b", UpdatedAt: base.Add(time.Second), CPU: v1.CPUReport{Usage: 40}, Network: v1.NetworkReport{TotalUp: 260}},
pending := []v2.Report{
{UUID: "node-a", UpdatedAt: base, CPU: v2.CPUReport{Usage: 10}, Network: v2.NetworkReport{TotalUp: 100}},
{UUID: "node-a", UpdatedAt: base, CPU: v2.CPUReport{Usage: 20}, Network: v2.NetworkReport{TotalUp: 150}},
{UUID: "node-b", UpdatedAt: base, CPU: v2.CPUReport{Usage: 30}, Network: v2.NetworkReport{TotalUp: 200}},
{UUID: "node-b", UpdatedAt: base.Add(time.Second), CPU: v2.CPUReport{Usage: 40}, Network: v2.NetworkReport{TotalUp: 260}},
}
if err := writePendingReports(ctx, &pending); err != nil {
@@ -292,11 +292,11 @@ func TestReportQueueFullReturnsError(t *testing.T) {
ctx := context.Background()
useReportTestStore(t, nil)
worker := &reportBatchWorker{
queue: make(chan v1.Report, 1),
queue: make(chan v2.Report, 1),
requests: make(chan reportBatchRequest, 1),
done: make(chan struct{}),
}
worker.queue <- v1.Report{UUID: "already-queued"}
worker.queue <- v2.Report{UUID: "already-queued"}
reportBatcherMu.Lock()
reportBatcher = worker
reportBatcherMu.Unlock()
@@ -308,7 +308,7 @@ func TestReportQueueFullReturnsError(t *testing.T) {
reportBatcherMu.Unlock()
})
report := v1.Report{
report := v2.Report{
UUID: "realtime-node",
UpdatedAt: time.Now().UTC(),
}
@@ -374,11 +374,11 @@ func TestWriteReportRebasesTrafficAfterAgentRestart(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
report := v1.Report{
report := v2.Report{
UUID: "restarted-node",
UpdatedAt: base,
Uptime: 1000,
Network: v1.NetworkReport{TotalUp: 100, TotalDown: 200},
Network: v2.NetworkReport{TotalUp: 100, TotalDown: 200},
}
if _, err := WriteReport(ctx, report); err != nil {
t.Fatalf("write first report: %v", err)
@@ -417,11 +417,11 @@ func TestWriteReportNormalizesReceiveTimeToUTC(t *testing.T) {
s := useReportTestStore(t, nil)
local := time.FixedZone("UTC+8", 8*60*60)
receiveTime := time.Now().In(local).Add(-10 * time.Second)
report := v1.Report{
report := v2.Report{
UUID: "utc-report",
UpdatedAt: receiveTime,
CPU: v1.CPUReport{Usage: 10},
Network: v1.NetworkReport{TotalUp: 1, TotalDown: 2},
CPU: v2.CPUReport{Usage: 10},
Network: v2.NetworkReport{TotalUp: 1, TotalDown: 2},
}
saved, err := WriteReport(ctx, report)
+1 -1
View File
@@ -22,7 +22,7 @@ const (
)
// wsFrameHookTimeout bounds one frame-level callback. The agent read pumps
// run on deadlines (v1: 11s, v2: readWait); a frame hook must never stall
// run on the agent read deadline; a frame hook must never stall
// them, so the wait is cut short and the frame passes through untouched.
const wsFrameHookTimeout = time.Second
-87
View File
@@ -1,87 +0,0 @@
package v1
import "time"
type Message struct {
Type string `json:"type"`
Content string `json:"content"`
Sender string `json:"sender"`
Timestamp int64 `json:"timestamp"`
}
type IPAddress struct {
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
}
type Report struct {
UUID string `json:"uuid,omitempty"`
CPU CPUReport `json:"cpu"`
Ram RamReport `json:"ram"`
Swap RamReport `json:"swap"`
Load LoadReport `json:"load"`
Disk DiskReport `json:"disk"`
Network NetworkReport `json:"network"`
Connections ConnectionsReport `json:"connections"`
GPU *GPUDetailReport `json:"gpu,omitempty"`
Uptime int64 `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
Method string `json:"method,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type CPUReport struct {
Name string `json:"name,omitempty"`
Cores int `json:"cores,omitempty"`
Arch string `json:"arch,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type GPUDetailReport struct {
Count int `json:"count"`
AverageUsage float64 `json:"average_usage"`
DetailedInfo []GPUDeviceInfo `json:"detailed_info"`
}
type GPUDeviceInfo struct {
Name string `json:"name"`
MemoryTotal int64 `json:"memory_total"`
MemoryUsed int64 `json:"memory_used"`
Utilization float64 `json:"utilization"`
Temperature int `json:"temperature"`
}
// GPUReport is the legacy v1 single-GPU shape kept for wire compatibility.
type GPUReport struct {
Name string `json:"name,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type RamReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type LoadReport struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
type DiskReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type NetworkReport struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
TotalUp int64 `json:"totalUp"`
TotalDown int64 `json:"totalDown"`
}
type ConnectionsReport struct {
TCP int `json:"tcp"`
UDP int `json:"udp"`
}
+87 -4
View File
@@ -3,8 +3,6 @@ package v2
import (
"encoding/json"
"time"
v1 "github.com/komari-monitor/komari/protocol/v1"
)
const (
@@ -52,8 +50,86 @@ type RPCError struct {
}
type ReportParams struct {
Report v1.Report `json:"report"`
AckEventIDs []string `json:"ack_event_ids,omitempty"`
Report Report `json:"report"`
AckEventIDs []string `json:"ack_event_ids,omitempty"`
}
type Message struct {
Type string `json:"type"`
Content string `json:"content"`
Sender string `json:"sender"`
Timestamp int64 `json:"timestamp"`
}
type IPAddress struct {
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
}
type Report struct {
UUID string `json:"uuid,omitempty"`
CPU CPUReport `json:"cpu"`
Ram RamReport `json:"ram"`
Swap RamReport `json:"swap"`
Load LoadReport `json:"load"`
Disk DiskReport `json:"disk"`
Network NetworkReport `json:"network"`
Connections ConnectionsReport `json:"connections"`
GPU *GPUDetailReport `json:"gpu,omitempty"`
Uptime int64 `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
Method string `json:"method,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type CPUReport struct {
Name string `json:"name,omitempty"`
Cores int `json:"cores,omitempty"`
Arch string `json:"arch,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type GPUDetailReport struct {
Count int `json:"count"`
AverageUsage float64 `json:"average_usage"`
DetailedInfo []GPUDeviceInfo `json:"detailed_info"`
}
type GPUDeviceInfo struct {
Name string `json:"name"`
MemoryTotal int64 `json:"memory_total"`
MemoryUsed int64 `json:"memory_used"`
Utilization float64 `json:"utilization"`
Temperature int `json:"temperature"`
}
type RamReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type LoadReport struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
type DiskReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type NetworkReport struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
TotalUp int64 `json:"totalUp"`
TotalDown int64 `json:"totalDown"`
}
type ConnectionsReport struct {
TCP int `json:"tcp"`
UDP int `json:"udp"`
}
type BasicInfoParams struct {
@@ -67,6 +143,13 @@ type PingResultParams struct {
FinishedAt time.Time `json:"finished_at"`
}
type TaskResultParams struct {
TaskID string `json:"task_id"`
Result string `json:"result"`
ExitCode int `json:"exit_code"`
FinishedAt time.Time `json:"finished_at"`
}
type PullParams struct {
Capabilities []string `json:"capabilities,omitempty"`
AckEventIDs []string `json:"ack_event_ids,omitempty"`
+1 -13
View File
@@ -57,18 +57,6 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
// executePingTask 执行单个PingTask
func executePingTask(ctx context.Context, task models.PingTask) {
var message struct {
TaskID uint `json:"ping_task_id"`
Message string `json:"message"`
Type string `json:"ping_type"`
Target string `json:"ping_target"`
}
message.Message = "ping"
message.TaskID = task.Id
message.Type = task.Type
message.Target = task.Target
for _, clientUUID := range targetPingClientUUIDs(task) {
select {
case <-ctx.Done():
@@ -78,7 +66,7 @@ func executePingTask(ctx context.Context, task models.PingTask) {
// Context is still active, continue.
}
agent_runtime.DispatchPing(clientUUID, message, v2.PingParams{TaskID: task.Id, Type: task.Type, Target: task.Target})
agent_runtime.DispatchPing(clientUUID, v2.PingParams{TaskID: task.Id, Type: task.Type, Target: task.Target})
}
}
+20 -19
View File
@@ -5,15 +5,15 @@ import (
"sync"
"time"
v1 "github.com/komari-monitor/komari/protocol/v1"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/web/connection"
)
var (
connectedClients = make(map[string]*connection.SafeConn)
connectedClientV2 = make(map[string]bool)
latestReport = make(map[string]*v1.Report)
recentReports = make(map[string][]v1.Report)
connectedClients = make(map[string]*connection.SafeConn)
v2Clients = make(map[string]struct{})
latestReport = make(map[string]*v2.Report)
recentReports = make(map[string][]v2.Report)
// presenceOnly stores online state for non-WebSocket agents.
// value keeps connectionID and a soft expiration to avoid flicker
presenceOnly = make(map[string]struct {
@@ -41,16 +41,17 @@ func SetConnectedClients(uuid string, conn *connection.SafeConn) {
connectedClients[uuid] = conn
}
func SetClientProtocolVersion(uuid string, version int) {
func MarkV2Client(uuid string) {
mu.Lock()
defer mu.Unlock()
connectedClientV2[uuid] = version >= 2
v2Clients[uuid] = struct{}{}
}
func IsV2Client(uuid string) bool {
mu.RLock()
defer mu.RUnlock()
return connectedClientV2[uuid]
_, ok := v2Clients[uuid]
return ok
}
func DeleteClientConditionally(uuid string, connToRemove *connection.SafeConn) {
@@ -60,7 +61,7 @@ func DeleteClientConditionally(uuid string, connToRemove *connection.SafeConn) {
// 检查当前 map 里的 conn 是否就是要删除的这一个
if currentConn, exists := connectedClients[uuid]; exists && currentConn == connToRemove {
delete(connectedClients, uuid)
delete(connectedClientV2, uuid)
delete(v2Clients, uuid)
}
}
func DeleteConnectedClients(uuid string) {
@@ -68,7 +69,7 @@ func DeleteConnectedClients(uuid string) {
defer mu.Unlock()
// 只从 map 中删除,不再负责关闭连接
delete(connectedClients, uuid)
delete(connectedClientV2, uuid)
delete(v2Clients, uuid)
}
// SetPresence sets or clears presence for non-WebSocket agents.
@@ -121,10 +122,10 @@ func GetAllOnlineUUIDs() []string {
}
return res
}
func GetLatestReport() map[string]*v1.Report {
func GetLatestReport() map[string]*v2.Report {
mu.RLock()
defer mu.RUnlock()
reportCopy := make(map[string]*v1.Report)
reportCopy := make(map[string]*v2.Report)
for k, v := range latestReport {
if v == nil {
continue
@@ -137,7 +138,7 @@ func GetLatestReport() map[string]*v1.Report {
// RecordReport updates the latest runtime state and keeps only the short raw
// window used by recent-status compatibility endpoints.
func RecordReport(report v1.Report) {
func RecordReport(report v2.Report) {
if report.UUID == "" {
return
}
@@ -161,30 +162,30 @@ func RecordReport(report v1.Report) {
insertAt := sort.Search(len(reports), func(i int) bool {
return reports[i].UpdatedAt.After(report.UpdatedAt)
})
reports = append(reports, v1.Report{})
reports = append(reports, v2.Report{})
copy(reports[insertAt+1:], reports[insertAt:])
reports[insertAt] = report
recentReports[report.UUID] = reports
}
func GetRecentReports(uuid string) []v1.Report {
func GetRecentReports(uuid string) []v2.Report {
mu.Lock()
defer mu.Unlock()
reports := reportsAfter(recentReports[uuid], time.Now().UTC().Add(-recentReportRetention))
if len(reports) == 0 {
delete(recentReports, uuid)
return []v1.Report{}
return []v2.Report{}
}
recentReports[uuid] = reports
return append([]v1.Report(nil), reports...)
return append([]v2.Report(nil), reports...)
}
func reportsAfter(reports []v1.Report, cutoff time.Time) []v1.Report {
func reportsAfter(reports []v2.Report, cutoff time.Time) []v2.Report {
first := 0
for first < len(reports) && reports[first].UpdatedAt.Before(cutoff) {
first++
}
out := make([]v1.Report, len(reports)-first)
out := make([]v2.Report, len(reports)-first)
copy(out, reports[first:])
return out
}
+6 -6
View File
@@ -4,15 +4,15 @@ import (
"testing"
"time"
v1 "github.com/komari-monitor/komari/protocol/v1"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
func TestRecordReportKeepsLatestAndShortRecentWindow(t *testing.T) {
mu.Lock()
previousLatest := latestReport
previousRecent := recentReports
latestReport = make(map[string]*v1.Report)
recentReports = make(map[string][]v1.Report)
latestReport = make(map[string]*v2.Report)
recentReports = make(map[string][]v2.Report)
mu.Unlock()
t.Cleanup(func() {
mu.Lock()
@@ -22,9 +22,9 @@ func TestRecordReportKeepsLatestAndShortRecentWindow(t *testing.T) {
})
now := time.Now().UTC()
RecordReport(v1.Report{UUID: "node-a", UpdatedAt: now.Add(-2 * time.Minute), CPU: v1.CPUReport{Usage: 10}})
RecordReport(v1.Report{UUID: "node-a", UpdatedAt: now.Add(-30 * time.Second), CPU: v1.CPUReport{Usage: 20}})
RecordReport(v1.Report{UUID: "node-a", UpdatedAt: now.Add(-45 * time.Second), CPU: v1.CPUReport{Usage: 15}})
RecordReport(v2.Report{UUID: "node-a", UpdatedAt: now.Add(-2 * time.Minute), CPU: v2.CPUReport{Usage: 10}})
RecordReport(v2.Report{UUID: "node-a", UpdatedAt: now.Add(-30 * time.Second), CPU: v2.CPUReport{Usage: 20}})
RecordReport(v2.Report{UUID: "node-a", UpdatedAt: now.Add(-45 * time.Second), CPU: v2.CPUReport{Usage: 15}})
recent := GetRecentReports("node-a")
if len(recent) != 2 || recent[0].CPU.Usage != 15 || recent[1].CPU.Usage != 20 {
+2 -5
View File
@@ -54,12 +54,9 @@ func DispatchV2Event(uuid, method string, params any) bool {
return true
}
func DispatchPing(uuid string, legacy any, params v2.PingParams) bool {
func DispatchPing(uuid string, params v2.PingParams) bool {
if conn := GetConnectedClients()[uuid]; conn != nil {
payload := legacy
if IsV2Client(uuid) {
payload = v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentPing, Params: params}
}
payload := v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentPing, Params: params}
if conn.WriteJSON(payload) == nil {
return true
}
+6 -7
View File
@@ -5,21 +5,20 @@ import (
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/internal/metricstore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
v1 "github.com/komari-monitor/komari/protocol/v1"
"github.com/komari-monitor/komari/internal/metricstore"
v2 "github.com/komari-monitor/komari/protocol/v2"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
// ingest.go
// agent 上报数据的传输无关处理逻辑。v1 (REST/WS) 与 v2 (JSON-RPC) 两套上报入口
// 经过各自的协议解析后,统一调用这里的函数落库并更新运行时状态,消除重复。
// agent 上报数据的传输无关处理逻辑。v2 的 HTTP/WebSocket JSON-RPC 入口
// 经过协议解析后,统一调用这里的函数落库并更新运行时状态。
// ingestReport 保存一次负载上报并刷新运行时状态。
// protocolVersion 标记上报所用协议(1 或 2),用于运行时区分客户端能力。
// markPresence 为 true 时按 POST 上报会话刷新在线状态(WS 连接自行管理在线状态,应传 false)。
func ingestReport(uuid string, report v1.Report, protocolVersion int, markPresence bool) error {
func ingestReport(uuid string, report v2.Report, markPresence bool) error {
report.UUID = uuid
report.UpdatedAt = time.Now().UTC()
if err := clients.ReportVerify(report); err != nil {
@@ -30,7 +29,7 @@ func ingestReport(uuid string, report v1.Report, protocolVersion int, markPresen
return err
}
agent_runtime.RecordReport(savedReport)
agent_runtime.SetClientProtocolVersion(uuid, protocolVersion)
agent_runtime.MarkV2Client(uuid)
if markPresence {
refreshPostPresence(uuid)
}
+69
View File
@@ -0,0 +1,69 @@
package client
import (
"sync"
"time"
"github.com/komari-monitor/komari/utils/notifier"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
const (
// 如果超过这个时间没有收到任何消息,则认为连接已死
readWait = 11 * time.Second
postPresenceTTL = 35 * time.Second
)
type postPresenceEntry struct {
connID int64
timer *time.Timer
generation uint64
}
var (
postPresenceMu sync.Mutex
postPresenceStates = make(map[string]*postPresenceEntry)
)
// refreshPostPresence 管理 HTTP POST 上报者的在线/离线状态。
func refreshPostPresence(uuid string) {
postPresenceMu.Lock()
defer postPresenceMu.Unlock()
if entry, exists := postPresenceStates[uuid]; exists {
entry.generation++
entry.timer.Stop()
gen := entry.generation
entry.timer = time.AfterFunc(postPresenceTTL, func() {
postPresenceExpired(uuid, entry.connID, gen)
})
agent_runtime.KeepAlivePresence(uuid, entry.connID, postPresenceTTL)
return
}
connID := time.Now().UnixNano()
agent_runtime.KeepAlivePresence(uuid, connID, postPresenceTTL)
agent_runtime.MarkV2Client(uuid)
go notifier.OnlineNotification(uuid, connID)
defaultGeneration := uint64(0)
entry := &postPresenceEntry{connID: connID, generation: defaultGeneration}
entry.timer = time.AfterFunc(postPresenceTTL, func() {
postPresenceExpired(uuid, connID, defaultGeneration)
})
postPresenceStates[uuid] = entry
}
func postPresenceExpired(uuid string, connID int64, gen uint64) {
postPresenceMu.Lock()
e, ok := postPresenceStates[uuid]
if !ok || e.connID != connID || e.generation != gen {
postPresenceMu.Unlock()
return
}
delete(postPresenceStates, uuid)
postPresenceMu.Unlock()
agent_runtime.SetPresence(uuid, connID, false)
notifier.OfflineNotification(uuid, connID)
}
-263
View File
@@ -1,263 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"sync"
"time"
logger "github.com/komari-monitor/komari/utils/log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/clients"
v1 "github.com/komari-monitor/komari/protocol/v1"
"github.com/komari-monitor/komari/utils/notifier"
agent_runtime "github.com/komari-monitor/komari/web/agent"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/connection"
)
const (
// 如果超过这个时间没有收到任何消息,则认为连接已死
// 因为目前server没有存agent的信息上报间隔。只有写一个默认的
readWait = 11 * time.Second
postPresenceTTL = 35 * time.Second
)
// postPresenceEntry 保存单个客户端的 POST 上报会话状态
type postPresenceEntry struct {
connID int64
timer *time.Timer
generation uint64 // 每次 Reset 递增,用于回调中判断是否为过期的旧回调
}
var (
postPresenceMu sync.Mutex
postPresenceStates = make(map[string]*postPresenceEntry)
)
// refreshPostPresence 管理 HTTP POST 上报者的在线/离线状态。
// 每次 POST 刷新 TTL 定时器;定时器到期后触发离线通知。
func refreshPostPresence(uuid string) {
postPresenceMu.Lock()
defer postPresenceMu.Unlock()
if entry, exists := postPresenceStates[uuid]; exists {
// 已在线:递增 generation 使可能正在执行的旧回调失效
entry.generation++
entry.timer.Stop()
// 重新创建 AfterFunc 以在闭包中捕获新的 generation
gen := entry.generation
entry.timer = time.AfterFunc(postPresenceTTL, func() {
postPresenceExpired(uuid, entry.connID, gen)
})
agent_runtime.KeepAlivePresence(uuid, entry.connID, postPresenceTTL)
return
}
// 新 POST 会话:生成 connID,标记在线,启动超时定时器
connID := time.Now().UnixNano()
agent_runtime.KeepAlivePresence(uuid, connID, postPresenceTTL)
agent_runtime.SetClientProtocolVersion(uuid, 1)
go notifier.OnlineNotification(uuid, connID)
defaultGeneration := uint64(0)
entry := &postPresenceEntry{
connID: connID,
generation: defaultGeneration,
}
entry.timer = time.AfterFunc(postPresenceTTL, func() {
postPresenceExpired(uuid, connID, defaultGeneration)
})
postPresenceStates[uuid] = entry
}
// postPresenceExpired 是定时器到期的回调。
// 只有当 connID 和 generation 都与当前 entry 匹配时才执行离线清理,
// 避免 timer.Reset 竞态导致过期回调错误地清除仍活跃的会话。
func postPresenceExpired(uuid string, connID int64, gen uint64) {
postPresenceMu.Lock()
e, ok := postPresenceStates[uuid]
if !ok || e.connID != connID || e.generation != gen {
postPresenceMu.Unlock()
return
}
delete(postPresenceStates, uuid)
postPresenceMu.Unlock()
agent_runtime.SetPresence(uuid, connID, false)
notifier.OfflineNotification(uuid, connID)
}
func UploadReport(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.ErrorArgs("client-api", "Failed to read request body:", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
var data map[string]interface{}
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Save report to database
var report v1.Report
err = json.Unmarshal(bodyBytes, &report)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// 优先使用 body 中的 UUID,若为空则从中间件注入的上下文中获取
uuid := report.UUID
if uuid == "" {
if v, ok := c.Get("client_uuid"); ok {
uuid, _ = v.(string)
}
}
if uuid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "UUID is required"})
return
}
// POST 上报:落库、更新运行时状态并刷新在线状态
if err := ingestReport(uuid, report, 1, true); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
}
func WebSocketReport(c *gin.Context) {
// 升级ws
if !api.IsWebSocketUpgrade(c) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
// Upgrade the HTTP connection to a WebSocket connection
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
}
defer conn.Close()
_, message, err := conn.ReadMessage()
if err != nil {
logger.ErrorArgs("client-api", "Error reading message:", err)
return
}
// 第一次数据拿token
data := map[string]interface{}{}
err = json.Unmarshal(message, &data)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
return
}
// it should ok,token was verfied in the middleware
token := ""
var errMsg string
// 优先检查查询参数中的 token
token = c.Query("token")
// 如果 token 为空,返回错误
if token == "" {
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
return
}
uuid, err := clients.GetClientUUIDByToken(token)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
return
}
// 接受新连接,并处理旧连接
if oldConn, exists := agent_runtime.GetConnectedClients()[uuid]; exists {
logger.Infof("client-api", "Client %s is reconnecting. Closing the old connection.", uuid)
// 强制关闭旧连接。这将导致旧连接的 ReadMessage() 循环出错退出。
go oldConn.Close()
}
agent_runtime.SetConnectedClients(uuid, conn)
logger.Infof("client-api", "Client %s is reconnect success, connID: %d", uuid, conn.ID)
go notifier.OnlineNotification(uuid, conn.ID)
defer func() {
agent_runtime.DeleteClientConditionally(uuid, conn)
notifier.OfflineNotification(uuid, conn.ID)
}()
// 首先处理第一次ws conn收到的消息
processMessage(conn, message, uuid)
for {
conn.SetReadDeadline(time.Now().Add(readWait))
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
logger.Errorf("client-api", "Client %s connection error: %v", uuid, err)
}
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
}
processMessage(conn, message, uuid)
}
}
// 将消息处理逻辑提取到一个函数中,方便复用
func processMessage(conn *connection.SafeConn, message []byte, uuid string) {
type MessageType struct {
Type string `json:"type"`
}
var msgType MessageType
err := json.Unmarshal(message, &msgType)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
return
}
switch msgType.Type {
case "", "report":
report := v1.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
return
}
// WS 连接自行管理在线状态,无需刷新 POST presence
if err := ingestReport(uuid, report, 1, false); err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
return
}
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
return
}
if err := ingestPingResult(uuid, reqBody.PingTaskID, reqBody.PingResult); err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Failed to save ping result"})
}
default:
logger.Warnf("client-api", "Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
}
}
+17 -3
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/tasks"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/utils/notifier"
agent_runtime "github.com/komari-monitor/komari/web/agent"
@@ -52,7 +53,7 @@ func handleV2RPC(uuid string, req v2.Request, allowWait bool) v2.Response {
if err := bindV2Params(req.Params, &params); err != nil {
return v2.Error(req.ID, -32602, "invalid report params", err.Error())
}
if err := ingestReport(uuid, params.Report, 2, true); err != nil {
if err := ingestReport(uuid, params.Report, true); err != nil {
return v2.Error(req.ID, -32000, "failed to save report", err.Error())
}
return v2.Success(req.ID, gin.H{
@@ -77,13 +78,26 @@ func handleV2RPC(uuid string, req v2.Request, allowWait bool) v2.Response {
return v2.Error(req.ID, -32000, "failed to save ping result", err.Error())
}
return v2.Success(req.ID, gin.H{"status": "success"})
case v2.MethodAgentTaskResult:
var params v2.TaskResultParams
if err := bindV2Params(req.Params, &params); err != nil {
return v2.Error(req.ID, -32602, "invalid task result params", err.Error())
}
finishedAt := params.FinishedAt
if finishedAt.IsZero() {
finishedAt = time.Now().UTC()
}
if err := tasks.SaveTaskResult(params.TaskID, uuid, params.Result, params.ExitCode, finishedAt); err != nil {
return v2.Error(req.ID, -32000, "failed to save task result", err.Error())
}
return v2.Success(req.ID, gin.H{"status": "success"})
case v2.MethodAgentPull:
var params v2.PullParams
if err := bindV2Params(req.Params, &params); err != nil {
return v2.Error(req.ID, -32602, "invalid pull params", err.Error())
}
refreshPostPresence(uuid)
agent_runtime.SetClientProtocolVersion(uuid, 2)
agent_runtime.MarkV2Client(uuid)
timeout := 0 * time.Second
if allowWait {
timeout = 25 * time.Second
@@ -151,7 +165,7 @@ func WebSocketV2RPC(c *gin.Context) {
go oldConn.Close()
}
agent_runtime.SetConnectedClients(uuid, conn)
agent_runtime.SetClientProtocolVersion(uuid, 2)
agent_runtime.MarkV2Client(uuid)
go notifierOnline(uuid, conn.ID)
defer func() {
agent_runtime.DeleteClientConditionally(uuid, conn)
-24
View File
@@ -6,8 +6,6 @@ import (
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/gin-gonic/gin"
)
func getClientIPType(ip net.IP) int {
@@ -80,25 +78,3 @@ func appendClientRegionFromGeoIP(info map[string]interface{}) {
return
}
}
func UploadBasicInfo(c *gin.Context) {
var cbi = map[string]interface{}{}
if err := c.ShouldBindJSON(&cbi); err != nil {
c.JSON(400, gin.H{"status": "error", "error": "Invalid or missing data"})
return
}
token := c.Query("token")
uuid, err := clients.GetClientUUIDByToken(token)
if uuid == "" || err != nil {
c.JSON(400, gin.H{"status": "error", "error": "Invalid token"})
return
}
if err := ingestBasicInfo(uuid, cbi, c.ClientIP()); err != nil {
c.JSON(500, gin.H{"status": "error", "error": err})
return
}
c.JSON(200, gin.H{"status": "success"})
}
+1 -11
View File
@@ -14,17 +14,7 @@ import (
)
func dispatchTerminalRequest(uuid, id string) bool {
if agent_runtime.IsV2Client(uuid) {
return agent_runtime.DispatchV2Event(uuid, v2.MethodAgentTerminal, v2.TerminalRequestParams{RequestID: id})
}
agent := agent_runtime.GetConnectedClients()[uuid]
if agent == nil {
return false
}
return agent.WriteJSON(gin.H{
"message": "terminal",
"request_id": id,
}) == nil
return agent_runtime.DispatchV2Event(uuid, v2.MethodAgentTerminal, v2.TerminalRequestParams{RequestID: id})
}
func RequestTerminal(c *gin.Context) {
+3 -3
View File
@@ -8,7 +8,7 @@ import (
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/protocol/v1"
v2 "github.com/komari-monitor/komari/protocol/v2"
agent_runtime "github.com/komari-monitor/komari/web/agent"
)
@@ -53,11 +53,11 @@ func GetClients(c *gin.Context) {
for {
var resp struct {
Online []string `json:"online"` // 已建立连接的客户端uuid列表
Data map[string]v1.Report `json:"data"` // 最后上报的数据
Data map[string]v2.Report `json:"data"` // 最后上报的数据
}
resp.Online = []string{}
resp.Data = map[string]v1.Report{}
resp.Data = map[string]v2.Report{}
_, data, err := conn.ReadMessage()
if err != nil {
+65
View File
@@ -280,6 +280,7 @@ func serveUploadTransfer(c *gin.Context, transfer *streamTransfer) {
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Length", strconv.FormatInt(transfer.size, 10))
c.Header("Content-Encoding", "identity")
c.Header("Cache-Control", "no-store, no-transform")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
@@ -325,6 +326,7 @@ func streamDownloadRelay(c *gin.Context, clientUUID, path string, fileSize int64
for offset < end {
length := min(currentChunkSize, end-offset)
var chunkErr error
var lastCopyResult streamCopyResult
for {
resized := false
chunkErr = nil
@@ -334,6 +336,7 @@ func streamDownloadRelay(c *gin.Context, clientUUID, path string, fileSize int64
commit = nil
}
copyResult, streamErr := streamDownloadChunk(c, clientUUID, path, fileSize, modifiedAt, offset, length, chunkIndex, commit)
lastCopyResult = copyResult
if copyResult.err == nil && copyResult.bytes == length {
// The request has proven that this logical block size is
// accepted. Remember it immediately so concurrent/follow-up
@@ -373,6 +376,18 @@ func streamDownloadRelay(c *gin.Context, clientUUID, path string, fileSize int64
if copyResult.bytes == 0 && isNonRetryableStreamError(chunkErr) {
return chunkErr
}
if copyResult.bytes == 0 && attempt == streamChunkAttempts && isChunkTransportError(chunkErr) {
adaptiveUpperBound = min(adaptiveUpperBound, length)
if next, ok := nextReducedTransferChunkSize(length, adaptiveUpperBound); ok && next < length {
previousLength := length
currentChunkSize = next
c.Set(downloadChunkSizeContextKey, currentChunkSize)
length = min(currentChunkSize, end-offset)
logger.Warnf("file-transfer", "reducing download chunk size after transport failure client=%s path=%q from=%d to=%d: %v", clientUUID, path, previousLength, currentChunkSize, chunkErr)
resized = true
break
}
}
// Once a byte of this logical chunk reached the browser, retrying
// would overlap an already committed HTTP response. Only retry when
// the failure happened before the first byte.
@@ -389,6 +404,16 @@ func streamDownloadRelay(c *gin.Context, clientUUID, path string, fileSize int64
break
}
if chunkErr != nil {
// A downstream browser/proxy can close a long response after some
// bytes were delivered. The current HTTP response cannot be resumed,
// but the next Range request can avoid repeating the same oversized
// logical block by remembering a smaller probe size.
if lastCopyResult.bytes > 0 && isClientDisconnectError(lastCopyResult.err) {
if next, ok := nextReducedTransferChunkSize(currentChunkSize, currentChunkSize); ok && next < currentChunkSize {
rememberDownloadChunkSize(clientUUID, next)
logger.Warnf("file-transfer", "reducing download chunk size after downstream disconnect client=%s from=%d to=%d", clientUUID, currentChunkSize, next)
}
}
return chunkErr
}
offset += length
@@ -518,6 +543,46 @@ func isRelayPipeError(err error) bool {
return strings.Contains(message, "closed pipe") || strings.Contains(message, "file transfer is closed")
}
func isClientDisconnectError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
for _, marker := range []string{
"broken pipe",
"connection reset by peer",
"connection reset",
"write: closed network connection",
"client disconnected",
} {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func isChunkTransportError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
for _, marker := range []string{
" 502 ",
" 503 ",
" 504 ",
"broken pipe",
"connection reset",
"unexpected eof",
"timeout",
} {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func isNonRetryableStreamError(err error) bool {
if err == nil {
return false
+1
View File
@@ -565,6 +565,7 @@ func downloadFile(c *gin.Context, clientUUID, path string, options downloadRespo
c.Header("Content-Disposition", formatDownloadContentDisposition(disposition, name, options.OfficeCompatible))
c.Header("Accept-Ranges", "bytes")
c.Header("Content-Length", strconv.FormatInt(contentLength, 10))
c.Header("Content-Encoding", "identity")
c.Header("Cache-Control", "no-store, no-transform")
c.Header("X-Accel-Buffering", "no")
c.Header("X-Komari-Transfer-Chunk-Size", strconv.FormatInt(headerChunkSize, 10))
+1 -1
View File
@@ -85,7 +85,7 @@ func TestRestrictedControllersRegisterUnifiedRoutes(t *testing.T) {
if routes["POST "+base+"/discard"] != test.discard {
t.Fatalf("POST %s/discard presence = %v, want %v", base, routes["POST "+base+"/discard"], test.discard)
}
if routes["GET /api/public"] || routes["GET /api/rpc2"] || routes["POST /api/clients/report"] {
if routes["GET /api/public"] || routes["GET /api/rpc2"] || routes["GET /api/clients/v2/rpc"] {
t.Fatalf("ordinary APIs leaked into restricted routes: %#v", routes)
}
+1 -9
View File
@@ -68,21 +68,13 @@ func registerAgentRoutes(r *gin.Engine) {
tokenAuthorized := r.Group("/api/clients", api.RequireRole(api.RoleAdmin, api.RoleClient))
{
// 上报类(WS / 原始流 / 兼容协议)保留 REST handler。
tokenAuthorized.GET("/report", client.WebSocketReport)
tokenAuthorized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthorized.POST("/report", client.UploadReport)
// Agent 上报统一使用 v2 JSON-RPC。
tokenAuthorized.GET("/v2/rpc", client.WebSocketV2RPC)
tokenAuthorized.POST("/v2/rpc", client.UploadV2RPC)
// File data uses a short-lived, raw HTTP stream opened by a file RPC.
tokenAuthorized.GET("/transfer/:id", filemanager.AgentTransfer)
tokenAuthorized.POST("/transfer/:id", filemanager.AgentTransfer)
tokenAuthorized.GET("/terminal", terminal.EstablishConnection)
// JSON 接口 -> RPC2 (client: 命名空间)。
tokenAuthorized.POST("/task/result", jsonRpc.Bind("client:taskResult", jsonRpc.WithRaw()))
tokenAuthorized.GET("/ping/tasks", jsonRpc.Bind("client:getPingTasks", jsonRpc.WithRaw()))
tokenAuthorized.POST("/ping/result", jsonRpc.Bind("client:uploadPingResult", jsonRpc.WithRaw()))
}
}
+3 -3
View File
@@ -115,7 +115,7 @@ handler 退化为薄适配层:解析 gin 参数 → 调 RPC → 把响应映
| --- | --- |
| `admin` | client CRUD、ping task、session/settings/weight、notification(load/offline/traffic)、clipboard、provider(messageSender/oidc)、task 查询、system(logs/cloudflared/exec/test)、xtermjs |
| `public` | getMe、getNodesInformation、getPublicSettings、getVersion、getClientRecentRecords、getRecordsByUUID、getPingRecords、getPublicPingTasks、recordVisitorEvent |
| `client` | getPingTasks、uploadPingResult、taskResult |
| Agent v2 | `agent.report`、`agent.basicInfo`、`agent.pingResult`、`agent.taskResult`、`agent.pull`(`/api/clients/v2/rpc`) |
### 声明式路由桥 `Bind`
@@ -136,6 +136,6 @@ r.GET("/api/admin/client/:uuid", jsonRpc.Bind("admin:getClient", jsonRpc.WithPat
### 保留为 REST 的接口(不走 RPC 桥)
二进制/流/重定向/特殊鉴权类,集中在 `web/api/admin`(2fa/theme/backup/update/oauth 绑定)、
`web/api/public`(login/logout/oauth/plugin)、`web/api/client`(report WS+POST、v2 RPC、uploadBasicInfo、terminal、AutoDiscovery 注册)。
`web/api/public`(login/logout/oauth/plugin)、`web/api/client`(v2 RPC、terminal、AutoDiscovery 注册)。
agent v1/v2 上报的核心逻辑已统一到 `web/api/client/ingest.go`。
agent v2 上报的核心逻辑统一在 `web/api/client/ingest.go`。
+1 -9
View File
@@ -131,15 +131,7 @@ func adminExec(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcE
return nil, rpc.MakeError(rpc.InternalError, "Failed to create task: "+err.Error(), nil)
}
for _, uuid := range onlineClients {
legacy := struct {
Message string `json:"message"`
Command string `json:"command"`
TaskId string `json:"task_id"`
}{Message: "exec", Command: params.Command, TaskId: taskId}
payload, _ := json.Marshal(legacy)
if agent_runtime.IsV2Client(uuid) {
payload, _ = json.Marshal(v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentExec, Params: v2.ExecParams{TaskID: taskId, Command: params.Command}})
}
payload, _ := json.Marshal(v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentExec, Params: v2.ExecParams{TaskID: taskId, Command: params.Command}})
client := agent_runtime.GetConnectedClients()[uuid]
if client == nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Client connection is null: "+uuid, nil)
-84
View File
@@ -1,84 +0,0 @@
package jsonrpc
import (
"context"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/rpc"
)
// client.go
// 客户端(agent)面向的 RPC2 方法(client 命名空间,client/admin 可调用)。
// 这些方法依赖 meta.ClientUUID 标识调用方客户端。
func init() {
regClient("getPingTasks", clientGetPingTasks, "Get ping tasks assigned to the calling client")
regClient("uploadPingResult", clientUploadPingResult, "Upload a ping result")
regClient("taskResult", clientTaskResult, "Upload an exec task result")
}
func regClient(name string, h rpc.Handler, summary string) {
RegisterWithGroupAndMeta(name, rpc.RoleClient, h, &rpc.MethodMeta{Name: "client:" + name, Summary: summary})
}
// callingClientUUID 返回发起调用的客户端 UUID。
func callingClientUUID(ctx context.Context) string {
if meta := rpc.MetaFromContext(ctx); meta != nil {
return meta.ClientUUID
}
return ""
}
func clientGetPingTasks(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
uuid := callingClientUUID(ctx)
if uuid == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "client_uuid not found", nil)
}
return tasks.GetPingTasksByClient(uuid), nil
}
func clientUploadPingResult(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
uuid := callingClientUUID(ctx)
if uuid == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "client_uuid not found", nil)
}
var params struct {
TaskID uint `json:"task_id"`
Value int `json:"value"`
PingType string `json:"ping_type"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request: "+err.Error(), nil)
}
record := models.PingRecord{
Client: uuid,
TaskId: params.TaskID,
Value: params.Value,
Time: time.Now().UTC(),
}
if err := tasks.SavePingRecord(record); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to save ping result: "+err.Error(), nil)
}
return map[string]any{"status": "success"}, nil
}
func clientTaskResult(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
uuid := callingClientUUID(ctx)
if uuid == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing token", nil)
}
var params struct {
TaskId string `json:"task_id"`
Result string `json:"result"`
ExitCode int `json:"exit_code"`
}
if err := req.BindParams(&params); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid request", nil)
}
if err := tasks.SaveTaskResult(params.TaskId, uuid, params.Result, params.ExitCode, time.Now().UTC()); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to update task result: "+err.Error(), nil)
}
return map[string]any{"status": "success", "message": "Task result updated successfully"}, nil
}
+3 -3
View File
@@ -15,7 +15,7 @@ import (
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/rpc"
"github.com/komari-monitor/komari/protocol/v1"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/utils"
agent_runtime "github.com/komari-monitor/komari/web/agent"
@@ -285,7 +285,7 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
req.BindParams(&params)
meta := rpc.MetaFromContext(ctx)
latest := agent_runtime.GetLatestReport() // map[string]*v1.Report (copy)
latest := agent_runtime.GetLatestReport()
onlineUUIDs := agent_runtime.GetAllOnlineUUIDs()
onlineSet := make(map[string]bool, len(onlineUUIDs))
for _, uuid := range onlineUUIDs {
@@ -350,7 +350,7 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
// 预取所有 ping 任务
pingTasks, _ := tasks.GetAllPingTasks()
appendOne := func(uuid string, rep *v1.Report) {
appendOne := func(uuid string, rep *v2.Report) {
if rep == nil {
return
}