495 lines
13 KiB
Go
495 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/activitylog"
|
|
"github.com/Silo-Server/silo-server/internal/logstream"
|
|
"github.com/Silo-Server/silo-server/internal/opslog"
|
|
)
|
|
|
|
type AdminLogsHandler struct {
|
|
opsRepo *opslog.Repo
|
|
auditRepo *activitylog.Repo
|
|
streamHub *logstream.Hub
|
|
}
|
|
|
|
func NewAdminLogsHandler(opsRepo *opslog.Repo, auditRepo *activitylog.Repo, streamHub *logstream.Hub) *AdminLogsHandler {
|
|
return &AdminLogsHandler{opsRepo: opsRepo, auditRepo: auditRepo, streamHub: streamHub}
|
|
}
|
|
|
|
func (h *AdminLogsHandler) HandleListOperationalLogs(w http.ResponseWriter, r *http.Request) {
|
|
opts, err := parseOperationalLogOptionsFromRequest(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
result, err := h.opsRepo.List(r.Context(), opts)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to query operational logs")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (h *AdminLogsHandler) HandleListAuditLogs(w http.ResponseWriter, r *http.Request) {
|
|
opts, err := parseAuditLogOptionsFromRequest(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
result, err := h.auditRepo.List(r.Context(), opts)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to query audit logs")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func parseOperationalLogOptionsFromRequest(r *http.Request) (opslog.ListOptions, error) {
|
|
opts := opslog.ListOptions{
|
|
Level: strings.TrimSpace(r.URL.Query().Get("level")),
|
|
Component: strings.TrimSpace(r.URL.Query().Get("component")),
|
|
NodeID: strings.TrimSpace(r.URL.Query().Get("node_id")),
|
|
RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")),
|
|
SessionID: strings.TrimSpace(r.URL.Query().Get("session_id")),
|
|
PlaybackSessionID: strings.TrimSpace(r.URL.Query().Get("playback_session_id")),
|
|
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
|
Cursor: strings.TrimSpace(r.URL.Query().Get("cursor")),
|
|
Limit: parseLimit(r, 100),
|
|
}
|
|
|
|
userID, err := parseOptionalIntQuery(r, "user_id")
|
|
if err != nil {
|
|
return opslog.ListOptions{}, err
|
|
}
|
|
opts.UserID = userID
|
|
|
|
if from, err := parseTimeQuery(r, "from"); err != nil {
|
|
return opslog.ListOptions{}, err
|
|
} else {
|
|
opts.From = from
|
|
}
|
|
if to, err := parseTimeQuery(r, "to"); err != nil {
|
|
return opslog.ListOptions{}, err
|
|
} else {
|
|
opts.To = to
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
func parseAuditLogOptionsFromRequest(r *http.Request) (activitylog.ListOptions, error) {
|
|
opts := activitylog.ListOptions{
|
|
Method: strings.TrimSpace(r.URL.Query().Get("method")),
|
|
PathPrefix: strings.TrimSpace(r.URL.Query().Get("path_prefix")),
|
|
ClientIP: strings.TrimSpace(r.URL.Query().Get("client_ip")),
|
|
RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")),
|
|
SessionID: strings.TrimSpace(r.URL.Query().Get("session_id")),
|
|
PlaybackSessionID: strings.TrimSpace(r.URL.Query().Get("playback_session_id")),
|
|
Cursor: strings.TrimSpace(r.URL.Query().Get("cursor")),
|
|
Limit: parseLimit(r, 100),
|
|
}
|
|
|
|
statusCode, err := parseOptionalIntQuery(r, "status_code")
|
|
if err != nil {
|
|
return activitylog.ListOptions{}, err
|
|
}
|
|
opts.StatusCode = statusCode
|
|
|
|
userID, err := parseOptionalIntQuery(r, "user_id")
|
|
if err != nil {
|
|
return activitylog.ListOptions{}, err
|
|
}
|
|
opts.UserID = userID
|
|
|
|
if from, err := parseTimeQuery(r, "from"); err != nil {
|
|
return activitylog.ListOptions{}, err
|
|
} else {
|
|
opts.From = from
|
|
}
|
|
if to, err := parseTimeQuery(r, "to"); err != nil {
|
|
return activitylog.ListOptions{}, err
|
|
} else {
|
|
opts.To = to
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
func parseTimeQuery(r *http.Request, key string) (*time.Time, error) {
|
|
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
ts, err := time.Parse(time.RFC3339, raw)
|
|
if err != nil {
|
|
return nil, invalidQueryError(key)
|
|
}
|
|
value := ts.UTC()
|
|
return &value, nil
|
|
}
|
|
|
|
func parseOptionalIntQuery(r *http.Request, key string) (*int, error) {
|
|
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return nil, invalidQueryError(key)
|
|
}
|
|
return &value, nil
|
|
}
|
|
|
|
func invalidQueryError(key string) error {
|
|
return &requestParseError{message: "Invalid " + key}
|
|
}
|
|
|
|
type requestParseError struct {
|
|
message string
|
|
}
|
|
|
|
func (e *requestParseError) Error() string {
|
|
return e.message
|
|
}
|
|
|
|
func parseLimit(r *http.Request, fallback int) int {
|
|
raw := strings.TrimSpace(r.URL.Query().Get("limit"))
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
limit, err := strconv.Atoi(raw)
|
|
if err != nil || limit <= 0 {
|
|
return fallback
|
|
}
|
|
if limit > 200 {
|
|
return 200
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func (h *AdminLogsHandler) HandleLogStreamWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
if h.streamHub == nil {
|
|
http.Error(w, "log stream unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
stream := logstream.Stream(strings.TrimSpace(r.URL.Query().Get("stream")))
|
|
if stream != logstream.StreamApp && stream != logstream.StreamAudit {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid stream")
|
|
return
|
|
}
|
|
|
|
var (
|
|
appOpts opslog.ListOptions
|
|
auditOpts activitylog.ListOptions
|
|
err error
|
|
)
|
|
switch stream {
|
|
case logstream.StreamApp:
|
|
appOpts, err = parseOperationalLogOptionsFromRequest(r)
|
|
case logstream.StreamAudit:
|
|
auditOpts, err = parseAuditLogOptionsFromRequest(r)
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
events, unsubscribe := h.streamHub.Subscribe(func(msg logstream.Message) bool {
|
|
return msg.Type == logstream.MessageTypeAppend && msg.Stream == stream
|
|
})
|
|
defer unsubscribe()
|
|
|
|
conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout))
|
|
conn.SetPongHandler(func(string) error {
|
|
return conn.SetReadDeadline(time.Now().Add(wsPingInterval + wsPongTimeout))
|
|
})
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for {
|
|
if _, _, err := conn.ReadMessage(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
buffered := drainBufferedMessages(events)
|
|
seenIDs := make(map[int64]struct{})
|
|
newestSnapshotID := int64(0)
|
|
|
|
switch stream {
|
|
case logstream.StreamApp:
|
|
if h.opsRepo == nil {
|
|
h.writeStreamError(conn, stream, "internal_error", "Operational log stream unavailable")
|
|
return
|
|
}
|
|
result, err := h.opsRepo.List(context.Background(), appOpts)
|
|
if err != nil {
|
|
slog.Error("admin log stream operational snapshot failed", "error", err)
|
|
h.writeStreamError(conn, stream, "internal_error", "Failed to query operational logs")
|
|
return
|
|
}
|
|
if len(result.Entries) > 0 {
|
|
newestSnapshotID = result.Entries[0].ID
|
|
}
|
|
for _, entry := range result.Entries {
|
|
seenIDs[entry.ID] = struct{}{}
|
|
}
|
|
if err := writeSnapshotMessage(conn, stream, result.Entries, result.NextCursor); err != nil {
|
|
return
|
|
}
|
|
buffered = append(buffered, drainBufferedMessages(events)...)
|
|
for _, msg := range buffered {
|
|
entry, ok := decodeAppEntry(msg)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if newestSnapshotID > 0 && entry.ID <= newestSnapshotID {
|
|
continue
|
|
}
|
|
if !matchesOperationalLog(appOpts, entry) {
|
|
continue
|
|
}
|
|
if _, ok := seenIDs[entry.ID]; ok {
|
|
continue
|
|
}
|
|
seenIDs[entry.ID] = struct{}{}
|
|
if err := conn.WriteJSON(msg); err != nil {
|
|
return
|
|
}
|
|
}
|
|
case logstream.StreamAudit:
|
|
if h.auditRepo == nil {
|
|
h.writeStreamError(conn, stream, "internal_error", "Audit log stream unavailable")
|
|
return
|
|
}
|
|
result, err := h.auditRepo.List(context.Background(), auditOpts)
|
|
if err != nil {
|
|
slog.Error("admin log stream audit snapshot failed", "error", err)
|
|
h.writeStreamError(conn, stream, "internal_error", "Failed to query audit logs")
|
|
return
|
|
}
|
|
if len(result.Entries) > 0 {
|
|
newestSnapshotID = result.Entries[0].ID
|
|
}
|
|
for _, entry := range result.Entries {
|
|
seenIDs[entry.ID] = struct{}{}
|
|
}
|
|
if err := writeSnapshotMessage(conn, stream, result.Entries, result.NextCursor); err != nil {
|
|
return
|
|
}
|
|
buffered = append(buffered, drainBufferedMessages(events)...)
|
|
for _, msg := range buffered {
|
|
entry, ok := decodeAuditEntry(msg)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if newestSnapshotID > 0 && entry.ID <= newestSnapshotID {
|
|
continue
|
|
}
|
|
if !matchesAuditLog(auditOpts, entry) {
|
|
continue
|
|
}
|
|
if _, ok := seenIDs[entry.ID]; ok {
|
|
continue
|
|
}
|
|
seenIDs[entry.ID] = struct{}{}
|
|
if err := conn.WriteJSON(msg); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
ticker := time.NewTicker(wsPingInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-done:
|
|
return
|
|
case msg, ok := <-events:
|
|
if !ok {
|
|
return
|
|
}
|
|
switch stream {
|
|
case logstream.StreamApp:
|
|
entry, ok := decodeAppEntry(msg)
|
|
if !ok || !matchesOperationalLog(appOpts, entry) {
|
|
continue
|
|
}
|
|
if _, ok := seenIDs[entry.ID]; ok {
|
|
continue
|
|
}
|
|
seenIDs[entry.ID] = struct{}{}
|
|
case logstream.StreamAudit:
|
|
entry, ok := decodeAuditEntry(msg)
|
|
if !ok || !matchesAuditLog(auditOpts, entry) {
|
|
continue
|
|
}
|
|
if _, ok := seenIDs[entry.ID]; ok {
|
|
continue
|
|
}
|
|
seenIDs[entry.ID] = struct{}{}
|
|
}
|
|
if err := conn.WriteJSON(msg); err != nil {
|
|
if websocket.IsCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
|
return
|
|
}
|
|
return
|
|
}
|
|
case <-ticker.C:
|
|
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeSnapshotMessage(conn *websocket.Conn, stream logstream.Stream, entries any, nextCursor string) error {
|
|
raw, err := json.Marshal(entries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return conn.WriteJSON(logstream.Message{
|
|
Type: logstream.MessageTypeSnapshot,
|
|
Stream: stream,
|
|
Entries: raw,
|
|
NextCursor: nextCursor,
|
|
})
|
|
}
|
|
|
|
func (h *AdminLogsHandler) writeStreamError(conn *websocket.Conn, stream logstream.Stream, code, message string) {
|
|
_ = conn.WriteJSON(logstream.Message{
|
|
Type: logstream.MessageTypeError,
|
|
Stream: stream,
|
|
Code: code,
|
|
Message: message,
|
|
})
|
|
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, message), time.Now().Add(5*time.Second))
|
|
}
|
|
|
|
func drainBufferedMessages(events <-chan logstream.Message) []logstream.Message {
|
|
buffered := make([]logstream.Message, 0, 8)
|
|
for {
|
|
select {
|
|
case msg, ok := <-events:
|
|
if !ok {
|
|
return buffered
|
|
}
|
|
buffered = append(buffered, msg)
|
|
default:
|
|
return buffered
|
|
}
|
|
}
|
|
}
|
|
|
|
func decodeAppEntry(msg logstream.Message) (opslog.EntryRow, bool) {
|
|
var entry opslog.EntryRow
|
|
if err := json.Unmarshal(msg.Entry, &entry); err != nil {
|
|
return opslog.EntryRow{}, false
|
|
}
|
|
return entry, true
|
|
}
|
|
|
|
func decodeAuditEntry(msg logstream.Message) (activitylog.AuditEntry, bool) {
|
|
var entry activitylog.AuditEntry
|
|
if err := json.Unmarshal(msg.Entry, &entry); err != nil {
|
|
return activitylog.AuditEntry{}, false
|
|
}
|
|
return entry, true
|
|
}
|
|
|
|
func matchesOperationalLog(opts opslog.ListOptions, entry opslog.EntryRow) bool {
|
|
if opts.From != nil && entry.Timestamp.Before(*opts.From) {
|
|
return false
|
|
}
|
|
if opts.To != nil && entry.Timestamp.After(*opts.To) {
|
|
return false
|
|
}
|
|
if opts.Level != "" && entry.Level != strings.ToLower(opts.Level) {
|
|
return false
|
|
}
|
|
if opts.Component != "" && entry.Component != opts.Component {
|
|
return false
|
|
}
|
|
if opts.NodeID != "" && entry.NodeID != opts.NodeID {
|
|
return false
|
|
}
|
|
if opts.RequestID != "" && entry.RequestID != opts.RequestID {
|
|
return false
|
|
}
|
|
if opts.UserID != nil {
|
|
if entry.UserID == nil || *entry.UserID != *opts.UserID {
|
|
return false
|
|
}
|
|
}
|
|
if opts.SessionID != "" && entry.SessionID != opts.SessionID {
|
|
return false
|
|
}
|
|
if opts.PlaybackSessionID != "" && entry.PlaybackSessionID != opts.PlaybackSessionID {
|
|
return false
|
|
}
|
|
if opts.Query != "" && !strings.Contains(strings.ToLower(entry.Message), strings.ToLower(opts.Query)) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func matchesAuditLog(opts activitylog.ListOptions, entry activitylog.AuditEntry) bool {
|
|
if opts.From != nil && entry.Timestamp.Before(*opts.From) {
|
|
return false
|
|
}
|
|
if opts.To != nil && entry.Timestamp.After(*opts.To) {
|
|
return false
|
|
}
|
|
if opts.Method != "" && entry.Method != strings.ToUpper(opts.Method) {
|
|
return false
|
|
}
|
|
if opts.StatusCode != nil && entry.StatusCode != *opts.StatusCode {
|
|
return false
|
|
}
|
|
if opts.PathPrefix != "" && !strings.HasPrefix(entry.Path, opts.PathPrefix) {
|
|
return false
|
|
}
|
|
if opts.ClientIP != "" && entry.ClientIP != opts.ClientIP {
|
|
return false
|
|
}
|
|
if opts.RequestID != "" && entry.RequestID != opts.RequestID {
|
|
return false
|
|
}
|
|
if opts.UserID != nil {
|
|
if entry.UserID == nil || *entry.UserID != *opts.UserID {
|
|
return false
|
|
}
|
|
}
|
|
if opts.SessionID != "" && entry.SessionID != opts.SessionID {
|
|
return false
|
|
}
|
|
if opts.PlaybackSessionID != "" && entry.PlaybackSessionID != opts.PlaybackSessionID {
|
|
return false
|
|
}
|
|
return true
|
|
}
|