// Copyright (C) 2019-2023 Nicola Murino // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, version 3. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package httpd import ( "encoding/csv" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/sftpgo/sdk/plugin/eventsearcher" "github.com/sftpgo/sdk/plugin/notifier" "github.com/drakkan/sftpgo/v2/internal/dataprovider" "github.com/drakkan/sftpgo/v2/internal/plugin" "github.com/drakkan/sftpgo/v2/internal/util" ) func getCommonSearchParamsFromRequest(r *http.Request) (eventsearcher.CommonSearchParams, error) { c := eventsearcher.CommonSearchParams{} c.Limit = 100 if _, ok := r.URL.Query()["limit"]; ok { limit, err := strconv.Atoi(r.URL.Query().Get("limit")) if err != nil { return c, util.NewValidationError(fmt.Sprintf("invalid limit: %v", err)) } if limit < 1 || limit > 1000 { return c, util.NewValidationError(fmt.Sprintf("limit is out of the 1-1000 range: %v", limit)) } c.Limit = limit } if _, ok := r.URL.Query()["order"]; ok { order := r.URL.Query().Get("order") if order != dataprovider.OrderASC && order != dataprovider.OrderDESC { return c, util.NewValidationError(fmt.Sprintf("invalid order %q", order)) } if order == dataprovider.OrderASC { c.Order = 1 } } if _, ok := r.URL.Query()["start_timestamp"]; ok { ts, err := strconv.ParseInt(r.URL.Query().Get("start_timestamp"), 10, 64) if err != nil { return c, util.NewValidationError(fmt.Sprintf("invalid start_timestamp: %v", err)) } c.StartTimestamp = ts } if _, ok := r.URL.Query()["end_timestamp"]; ok { ts, err := strconv.ParseInt(r.URL.Query().Get("end_timestamp"), 10, 64) if err != nil { return c, util.NewValidationError(fmt.Sprintf("invalid end_timestamp: %v", err)) } c.EndTimestamp = ts } c.Username = r.URL.Query().Get("username") c.IP = r.URL.Query().Get("ip") c.InstanceIDs = getCommaSeparatedQueryParam(r, "instance_ids") c.FromID = r.URL.Query().Get("from_id") return c, nil } func getFsSearchParamsFromRequest(r *http.Request) (eventsearcher.FsEventSearch, error) { var err error s := eventsearcher.FsEventSearch{} s.CommonSearchParams, err = getCommonSearchParamsFromRequest(r) if err != nil { return s, err } s.FsProvider = -1 if _, ok := r.URL.Query()["fs_provider"]; ok { provider := r.URL.Query().Get("fs_provider") val, err := strconv.Atoi(provider) if err != nil { return s, util.NewValidationError(fmt.Sprintf("invalid fs_provider: %v", provider)) } s.FsProvider = val } s.Actions = getCommaSeparatedQueryParam(r, "actions") s.SSHCmd = r.URL.Query().Get("ssh_cmd") s.Bucket = r.URL.Query().Get("bucket") s.Endpoint = r.URL.Query().Get("endpoint") s.Protocols = getCommaSeparatedQueryParam(r, "protocols") statuses := getCommaSeparatedQueryParam(r, "statuses") for _, status := range statuses { val, err := strconv.ParseInt(status, 10, 32) if err != nil { return s, util.NewValidationError(fmt.Sprintf("invalid status: %v", status)) } s.Statuses = append(s.Statuses, int32(val)) } return s, nil } func getProviderSearchParamsFromRequest(r *http.Request) (eventsearcher.ProviderEventSearch, error) { var err error s := eventsearcher.ProviderEventSearch{} s.CommonSearchParams, err = getCommonSearchParamsFromRequest(r) if err != nil { return s, err } s.Actions = getCommaSeparatedQueryParam(r, "actions") s.ObjectName = r.URL.Query().Get("object_name") s.ObjectTypes = getCommaSeparatedQueryParam(r, "object_types") return s, nil } func getLogSearchParamsFromRequest(r *http.Request) (eventsearcher.LogEventSearch, error) { var err error s := eventsearcher.LogEventSearch{} s.CommonSearchParams, err = getCommonSearchParamsFromRequest(r) if err != nil { return s, err } s.Protocols = getCommaSeparatedQueryParam(r, "protocols") events := getCommaSeparatedQueryParam(r, "events") for _, ev := range events { evType, err := strconv.ParseUint(ev, 10, 32) if err == nil { s.Events = append(s.Events, int32(evType)) } } return s, nil } func searchFsEvents(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) claims, err := getTokenClaims(r) if err != nil || claims.Username == "" { sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) return } filters, err := getFsSearchParamsFromRequest(r) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return } filters.Role = getRoleFilterForEventSearch(r, claims.Role) if getBoolQueryParam(r, "csv_export") { filters.Limit = 100 if err := exportFsEvents(w, &filters); err != nil { panic(http.ErrAbortHandler) } return } data, err := plugin.Handler.SearchFsEvents(&filters) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return } w.Header().Set("Content-Type", "application/json") w.Write(data) //nolint:errcheck } func searchProviderEvents(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) claims, err := getTokenClaims(r) if err != nil || claims.Username == "" { sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) return } var filters eventsearcher.ProviderEventSearch if filters, err = getProviderSearchParamsFromRequest(r); err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return } filters.Role = getRoleFilterForEventSearch(r, claims.Role) filters.OmitObjectData = getBoolQueryParam(r, "omit_object_data") if getBoolQueryParam(r, "csv_export") { filters.Limit = 100 filters.OmitObjectData = true if err := exportProviderEvents(w, &filters); err != nil { panic(http.ErrAbortHandler) } return } data, err := plugin.Handler.SearchProviderEvents(&filters) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return } w.Header().Set("Content-Type", "application/json") w.Write(data) //nolint:errcheck } func searchLogEvents(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) claims, err := getTokenClaims(r) if err != nil || claims.Username == "" { sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) return } var filters eventsearcher.LogEventSearch if filters, err = getLogSearchParamsFromRequest(r); err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return } filters.Role = getRoleFilterForEventSearch(r, claims.Role) if getBoolQueryParam(r, "csv_export") { filters.Limit = 100 if err := exportLogEvents(w, &filters); err != nil { panic(http.ErrAbortHandler) } return } data, err := plugin.Handler.SearchLogEvents(&filters) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return } w.Header().Set("Content-Type", "application/json") w.Write(data) //nolint:errcheck } func exportFsEvents(w http.ResponseWriter, filters *eventsearcher.FsEventSearch) error { w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=fslogs-%s.csv", time.Now().Format("2006-01-02T15-04-05"))) w.Header().Set("Content-Type", "text/csv") w.Header().Set("Accept-Ranges", "none") w.WriteHeader(http.StatusOK) csvWriter := csv.NewWriter(w) ev := fsEvent{} err := csvWriter.Write(ev.getCSVHeader()) if err != nil { return err } results := make([]fsEvent, 0, filters.Limit) for { data, err := plugin.Handler.SearchFsEvents(filters) if err != nil { return err } if err := json.Unmarshal(data, &results); err != nil { return err } for _, event := range results { if err := csvWriter.Write(event.getCSVData()); err != nil { return err } } if len(results) == 0 || len(results) < filters.Limit { break } filters.StartTimestamp = results[len(results)-1].Timestamp filters.FromID = results[len(results)-1].ID results = nil } csvWriter.Flush() return csvWriter.Error() } func exportProviderEvents(w http.ResponseWriter, filters *eventsearcher.ProviderEventSearch) error { w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=providerlogs-%s.csv", time.Now().Format("2006-01-02T15-04-05"))) w.Header().Set("Content-Type", "text/csv") w.Header().Set("Accept-Ranges", "none") w.WriteHeader(http.StatusOK) ev := providerEvent{} csvWriter := csv.NewWriter(w) err := csvWriter.Write(ev.getCSVHeader()) if err != nil { return err } results := make([]providerEvent, 0, filters.Limit) for { data, err := plugin.Handler.SearchProviderEvents(filters) if err != nil { return err } if err := json.Unmarshal(data, &results); err != nil { return err } for _, event := range results { if err := csvWriter.Write(event.getCSVData()); err != nil { return err } } if len(results) < filters.Limit || len(results) == 0 { break } filters.FromID = results[len(results)-1].ID filters.StartTimestamp = results[len(results)-1].Timestamp results = nil } csvWriter.Flush() return csvWriter.Error() } func exportLogEvents(w http.ResponseWriter, filters *eventsearcher.LogEventSearch) error { w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=logs-%s.csv", time.Now().Format("2006-01-02T15-04-05"))) w.Header().Set("Content-Type", "text/csv") w.Header().Set("Accept-Ranges", "none") w.WriteHeader(http.StatusOK) ev := logEvent{} csvWriter := csv.NewWriter(w) err := csvWriter.Write(ev.getCSVHeader()) if err != nil { return err } results := make([]logEvent, 0, filters.Limit) for { data, err := plugin.Handler.SearchLogEvents(filters) if err != nil { return err } if err := json.Unmarshal(data, &results); err != nil { return err } for _, event := range results { if err := csvWriter.Write(event.getCSVData()); err != nil { return err } } if len(results) == 0 || len(results) < filters.Limit { break } filters.StartTimestamp = results[len(results)-1].Timestamp filters.FromID = results[len(results)-1].ID results = nil } csvWriter.Flush() return csvWriter.Error() } func getRoleFilterForEventSearch(r *http.Request, defaultValue string) string { if defaultValue != "" { return defaultValue } return r.URL.Query().Get("role") } type fsEvent struct { ID string `json:"id"` Timestamp int64 `json:"timestamp"` Action string `json:"action"` Username string `json:"username"` FsPath string `json:"fs_path"` FsTargetPath string `json:"fs_target_path,omitempty"` VirtualPath string `json:"virtual_path"` VirtualTargetPath string `json:"virtual_target_path,omitempty"` SSHCmd string `json:"ssh_cmd,omitempty"` FileSize int64 `json:"file_size,omitempty"` Elapsed int64 `json:"elapsed,omitempty"` Status int `json:"status"` Protocol string `json:"protocol"` IP string `json:"ip,omitempty"` SessionID string `json:"session_id"` FsProvider int `json:"fs_provider"` Bucket string `json:"bucket,omitempty"` Endpoint string `json:"endpoint,omitempty"` OpenFlags int `json:"open_flags,omitempty"` Role string `json:"role,omitempty"` InstanceID string `json:"instance_id,omitempty"` } func (e *fsEvent) getCSVHeader() []string { return []string{"Time", "Action", "Path", "Size", "Elapsed", "Status", "User", "Protocol", "IP", "SSH command"} } func (e *fsEvent) getCSVData() []string { timestamp := time.Unix(0, e.Timestamp).UTC() var pathInfo strings.Builder pathInfo.Write([]byte(e.VirtualPath)) if e.VirtualTargetPath != "" { pathInfo.WriteString(" => ") pathInfo.WriteString(e.VirtualTargetPath) } var status string switch e.Status { case 1: status = "OK" case 2: status = "KO" case 3: status = "Quota exceeded" } var fileSize string if e.FileSize > 0 { fileSize = util.ByteCountIEC(e.FileSize) } var elapsed string if e.Elapsed > 0 { elapsed = (time.Duration(e.Elapsed) * time.Millisecond).String() } return []string{timestamp.Format(time.RFC3339Nano), e.Action, pathInfo.String(), fileSize, elapsed, status, e.Username, e.Protocol, e.IP, e.SSHCmd} } type providerEvent struct { ID string `json:"id"` Timestamp int64 `json:"timestamp"` Action string `json:"action"` Username string `json:"username"` IP string `json:"ip,omitempty"` ObjectType string `json:"object_type"` ObjectName string `json:"object_name"` ObjectData []byte `json:"object_data"` Role string `json:"role,omitempty"` InstanceID string `json:"instance_id,omitempty"` } func (e *providerEvent) getCSVHeader() []string { return []string{"Time", "Action", "Object Type", "Object Name", "User", "IP"} } func (e *providerEvent) getCSVData() []string { timestamp := time.Unix(0, e.Timestamp).UTC() return []string{timestamp.Format(time.RFC3339Nano), e.Action, e.ObjectType, e.ObjectName, e.Username, e.IP} } type logEvent struct { ID string `json:"id"` Timestamp int64 `json:"timestamp"` Event int `json:"event"` Protocol string `json:"protocol"` Username string `json:"username,omitempty"` IP string `json:"ip,omitempty"` Message string `json:"message,omitempty"` Role string `json:"role,omitempty"` } func (e *logEvent) getCSVHeader() []string { return []string{"Time", "Event", "Protocol", "User", "IP", "Message"} } func (e *logEvent) getCSVData() []string { timestamp := time.Unix(0, e.Timestamp).UTC() return []string{timestamp.Format(time.RFC3339Nano), getLogEventString(notifier.LogEventType(e.Event)), e.Protocol, e.Username, e.IP, e.Message} } func getLogEventString(event notifier.LogEventType) string { switch event { case notifier.LogEventTypeLoginFailed: return "Login failed" case notifier.LogEventTypeLoginNoUser: return "Login with non-existent user" case notifier.LogEventTypeNoLoginTried: return "No login tried" case notifier.LogEventTypeNotNegotiated: return "Algorithm negotiation failed" default: return "" } }