fleet/server/service/base_client_errors.go
Victor Lyuboslavsky 8f0800a185
Improved orbit debug logs when response contains a large HTML page. (#33195)
Resolves #33219

Note: this only fixes orbit. The issue remains on osquery:
[#33019](https://github.com/fleetdm/fleet/issues/33019)

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

## fleetd/orbit/Fleet Desktop

- [x] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
- [x] Verified that fleetd runs on macOS, Linux and Windows
- [x] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Bug Fixes
  - Improved error messages when servers return HTML instead of JSON.
- Truncates oversized responses in logs to prevent overwhelming output
while preserving context.
  - More robust parsing of non-JSON error responses.

- Documentation
- Added changelog entry noting enhanced debug logging for large HTML
responses.

- Tests
- Added tests covering HTML, plain text, empty, long, and invalid JSON
error bodies to validate error message handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 17:00:19 -05:00

197 lines
4.7 KiB
Go

package service
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/fleetdm/fleet/v4/server/fleet"
)
var (
ErrUnauthenticated = errors.New("unauthenticated, or invalid token")
ErrPasswordResetRequired = errors.New("Password reset required. Please sign into the Fleet UI to update your password, then log in again with: fleetctl login.")
ErrMissingLicense = errors.New("missing or invalid license")
)
type SetupAlreadyErr interface {
SetupAlready() bool
Error() string
}
type setupAlreadyErr struct{}
func (e setupAlreadyErr) Error() string {
return "Fleet has already been setup"
}
func (e setupAlreadyErr) SetupAlready() bool {
return true
}
type NotSetupErr interface {
NotSetup() bool
Error() string
}
type notSetupErr struct{}
func (e notSetupErr) Error() string {
return "The Fleet instance is not set up yet"
}
func (e notSetupErr) NotSetup() bool {
return true
}
// TODO: we have a similar but different interface in the fleet package,
// fleet.NotFoundError - at the very least, the NotFound method should be the
// same in both (the other is currently IsNotFound), and ideally we'd just have
// one of those interfaces.
type NotFoundErr interface {
NotFound() bool
Error() string
}
type notFoundErr struct {
msg string
fleet.ErrorWithUUID
}
func (e notFoundErr) Error() string {
if e.msg != "" {
return e.msg
}
return "The resource was not found"
}
func (e notFoundErr) NotFound() bool {
return true
}
// Implement Is so that errors.Is(err, sql.ErrNoRows) returns true for an
// error of type *notFoundError, without having to wrap sql.ErrNoRows
// explicitly.
func (e notFoundErr) Is(other error) bool {
return other == sql.ErrNoRows
}
type ConflictErr interface {
Conflict() bool
Error() string
}
type conflictErr struct {
msg string
}
func (e conflictErr) Error() string {
return e.msg
}
func (e conflictErr) Conflict() bool {
return true
}
type serverError struct {
Message string `json:"message"`
Errors []struct {
Name string `json:"name"`
Reason string `json:"reason"`
} `json:"errors"`
}
// truncateAndDetectHTML truncates a response body to a reasonable length and
// detects if it's HTML content. Returns the truncated body and whether it's HTML.
func truncateAndDetectHTML(body []byte, maxLen int) (truncated []byte, isHTML bool) {
if len(body) > maxLen {
// Use append which is more idiomatic and efficient
truncated = append([]byte(nil), body[:maxLen]...)
truncated = append(truncated, "..."...)
} else {
// For small bodies, we can return the slice directly since it will be
// converted to string soon anyway and won't hold a large underlying array
truncated = body
}
lowerPrefix := bytes.ToLower(truncated)
isHTML = bytes.Contains(lowerPrefix, []byte("<html")) || bytes.Contains(lowerPrefix, []byte("<!doctype"))
// Return truncated byte slice
return truncated, isHTML
}
func extractServerErrorText(body io.Reader) string {
_, reason := extractServerErrorNameReason(body)
return reason
}
func extractServerErrorNameReason(body io.Reader) (string, string) {
// Read the body first so we can try to parse it as JSON and fallback to text if needed
bodyBytes, err := io.ReadAll(body)
if err != nil {
return "", "failed to read response body"
}
// Try to parse as JSON first
var serverErr serverError
if err := json.Unmarshal(bodyBytes, &serverErr); err != nil {
// If it's not JSON, it might be HTML or plain text error from a proxy/load balancer
const maxLen = 200
truncatedBytes, isHTML := truncateAndDetectHTML(bodyBytes, maxLen)
if isHTML {
// Generic HTML response
return "", fmt.Sprintf("server returned HTML instead of JSON response, body: %s", truncatedBytes)
}
// Return cleaned up text for non-HTML responses
truncated := strings.TrimSpace(string(truncatedBytes))
if truncated == "" {
return "", "empty response body"
}
return "", truncated
}
errName := ""
errReason := serverErr.Message
if len(serverErr.Errors) > 0 {
errReason += ": " + serverErr.Errors[0].Reason
errName = serverErr.Errors[0].Name
}
return errName, errReason
}
func extractServerErrorNameReasons(body io.Reader) ([]string, []string) {
var serverErr serverError
if err := json.NewDecoder(body).Decode(&serverErr); err != nil {
return []string{""}, []string{"unknown"}
}
var errName []string
var errReason []string
for _, err := range serverErr.Errors {
errName = append(errName, err.Name)
errReason = append(errReason, err.Reason)
}
return errName, errReason
}
type statusCodeErr struct {
code int
body string
}
func (e *statusCodeErr) Error() string {
return fmt.Sprintf("%d %s", e.code, e.body)
}
func (e *statusCodeErr) StatusCode() int {
return e.code
}