You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
340 lines
10 KiB
340 lines
10 KiB
|
2 months ago
|
// Package supportbundle creates bounded, redacted diagnostic archives without
|
||
|
|
// accepting arbitrary source paths from API clients.
|
||
|
|
package supportbundle
|
||
|
|
|
||
|
|
import (
|
||
|
|
"archive/zip"
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"tera-clone/services/tera-api/internal/storage/atomicfile"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
defaultMaxArtifacts = 4
|
||
|
|
defaultMaxBytes = 8 * 1024 * 1024
|
||
|
|
defaultMaxSection = 2 * 1024 * 1024
|
||
|
|
maxSections = 16
|
||
|
|
)
|
||
|
|
|
||
|
|
type Options struct {
|
||
|
|
Directory string
|
||
|
|
MaxArtifacts int
|
||
|
|
MaxBytes int
|
||
|
|
MaxSection int
|
||
|
|
}
|
||
|
|
|
||
|
|
type Section struct {
|
||
|
|
Name string
|
||
|
|
Value any
|
||
|
|
}
|
||
|
|
|
||
|
|
type CreateRequest struct {
|
||
|
|
BundleID string
|
||
|
|
GeneratedAt time.Time
|
||
|
|
Sections []Section
|
||
|
|
}
|
||
|
|
|
||
|
|
type Artifact struct {
|
||
|
|
BundleID string
|
||
|
|
Filename string
|
||
|
|
SizeBytes int
|
||
|
|
SHA256 string
|
||
|
|
Sections []string
|
||
|
|
Content []byte
|
||
|
|
}
|
||
|
|
|
||
|
|
type Service interface {
|
||
|
|
Create(context.Context, CreateRequest) (Artifact, error)
|
||
|
|
Read(context.Context, string) (Artifact, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type Manager struct {
|
||
|
|
directory string
|
||
|
|
maxArtifacts int
|
||
|
|
maxBytes int
|
||
|
|
maxSection int
|
||
|
|
mu sync.Mutex
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewManager(options Options) (*Manager, error) {
|
||
|
|
if options.Directory == "" || !filepath.IsAbs(options.Directory) {
|
||
|
|
return nil, errors.New("support bundle directory must be an absolute path")
|
||
|
|
}
|
||
|
|
if options.MaxArtifacts == 0 {
|
||
|
|
options.MaxArtifacts = defaultMaxArtifacts
|
||
|
|
}
|
||
|
|
if options.MaxBytes == 0 {
|
||
|
|
options.MaxBytes = defaultMaxBytes
|
||
|
|
}
|
||
|
|
if options.MaxSection == 0 {
|
||
|
|
options.MaxSection = defaultMaxSection
|
||
|
|
}
|
||
|
|
if options.MaxArtifacts < 1 || options.MaxArtifacts > 32 {
|
||
|
|
return nil, errors.New("support bundle max artifacts must be between 1 and 32")
|
||
|
|
}
|
||
|
|
if options.MaxBytes < 64*1024 || options.MaxBytes > 64*1024*1024 {
|
||
|
|
return nil, errors.New("support bundle max bytes must be between 65536 and 67108864")
|
||
|
|
}
|
||
|
|
if options.MaxSection < 1024 || options.MaxSection > options.MaxBytes {
|
||
|
|
return nil, errors.New("support bundle max section must be between 1024 and max bytes")
|
||
|
|
}
|
||
|
|
return &Manager{
|
||
|
|
directory: filepath.Clean(options.Directory),
|
||
|
|
maxArtifacts: options.MaxArtifacts,
|
||
|
|
maxBytes: options.MaxBytes,
|
||
|
|
maxSection: options.MaxSection,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) Create(ctx context.Context, request CreateRequest) (Artifact, error) {
|
||
|
|
if err := validateBundleID(request.BundleID); err != nil {
|
||
|
|
return Artifact{}, err
|
||
|
|
}
|
||
|
|
if request.GeneratedAt.IsZero() {
|
||
|
|
return Artifact{}, errors.New("support bundle generated time is required")
|
||
|
|
}
|
||
|
|
if len(request.Sections) == 0 || len(request.Sections) > maxSections {
|
||
|
|
return Artifact{}, fmt.Errorf("support bundle must contain between 1 and %d sections", maxSections)
|
||
|
|
}
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return Artifact{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
m.mu.Lock()
|
||
|
|
defer m.mu.Unlock()
|
||
|
|
if err := ensureDirectory(m.directory); err != nil {
|
||
|
|
return Artifact{}, err
|
||
|
|
}
|
||
|
|
filename := request.BundleID + ".zip"
|
||
|
|
artifactPath := filepath.Join(m.directory, filename)
|
||
|
|
if _, err := os.Lstat(artifactPath); err == nil {
|
||
|
|
return Artifact{}, errors.New("support bundle artifact already exists")
|
||
|
|
} else if !os.IsNotExist(err) {
|
||
|
|
return Artifact{}, fmt.Errorf("inspect support bundle artifact: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
content, sectionNames, err := m.build(ctx, request)
|
||
|
|
if err != nil {
|
||
|
|
return Artifact{}, err
|
||
|
|
}
|
||
|
|
if err := atomicfile.Write(artifactPath, content, 0o600); err != nil {
|
||
|
|
return Artifact{}, fmt.Errorf("persist support bundle: %w", err)
|
||
|
|
}
|
||
|
|
if err := m.trimLocked(); err != nil {
|
||
|
|
return Artifact{}, err
|
||
|
|
}
|
||
|
|
digest := sha256.Sum256(content)
|
||
|
|
return Artifact{
|
||
|
|
BundleID: request.BundleID, Filename: filename, SizeBytes: len(content),
|
||
|
|
SHA256: hex.EncodeToString(digest[:]), Sections: sectionNames,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) Read(ctx context.Context, bundleID string) (Artifact, error) {
|
||
|
|
if err := validateBundleID(bundleID); err != nil {
|
||
|
|
return Artifact{}, err
|
||
|
|
}
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return Artifact{}, err
|
||
|
|
}
|
||
|
|
m.mu.Lock()
|
||
|
|
defer m.mu.Unlock()
|
||
|
|
if err := ensureDirectory(m.directory); err != nil {
|
||
|
|
return Artifact{}, err
|
||
|
|
}
|
||
|
|
filename := bundleID + ".zip"
|
||
|
|
file, err := os.Open(filepath.Join(m.directory, filename))
|
||
|
|
if err != nil {
|
||
|
|
if os.IsNotExist(err) {
|
||
|
|
return Artifact{}, errors.New("support bundle not found")
|
||
|
|
}
|
||
|
|
return Artifact{}, fmt.Errorf("open support bundle: %w", err)
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
info, err := file.Stat()
|
||
|
|
if err != nil {
|
||
|
|
return Artifact{}, fmt.Errorf("inspect support bundle: %w", err)
|
||
|
|
}
|
||
|
|
if !info.Mode().IsRegular() || info.Size() < 1 || info.Size() > int64(m.maxBytes) {
|
||
|
|
return Artifact{}, errors.New("support bundle artifact is not a bounded regular file")
|
||
|
|
}
|
||
|
|
content, err := io.ReadAll(io.LimitReader(file, int64(m.maxBytes)+1))
|
||
|
|
if err != nil {
|
||
|
|
return Artifact{}, fmt.Errorf("read support bundle: %w", err)
|
||
|
|
}
|
||
|
|
if len(content) > m.maxBytes {
|
||
|
|
return Artifact{}, errors.New("support bundle artifact exceeds the configured limit")
|
||
|
|
}
|
||
|
|
digest := sha256.Sum256(content)
|
||
|
|
return Artifact{
|
||
|
|
BundleID: bundleID, Filename: filename, SizeBytes: len(content),
|
||
|
|
SHA256: hex.EncodeToString(digest[:]), Content: content,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) build(ctx context.Context, request CreateRequest) ([]byte, []string, error) {
|
||
|
|
sectionNames := make([]string, 0, len(request.Sections))
|
||
|
|
sectionContent := make(map[string][]byte, len(request.Sections))
|
||
|
|
seen := make(map[string]bool, len(request.Sections))
|
||
|
|
totalSectionBytes := 0
|
||
|
|
for _, section := range request.Sections {
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return nil, nil, err
|
||
|
|
}
|
||
|
|
if err := validateSectionName(section.Name); err != nil {
|
||
|
|
return nil, nil, err
|
||
|
|
}
|
||
|
|
if seen[section.Name] {
|
||
|
|
return nil, nil, fmt.Errorf("support bundle section %q is duplicated", section.Name)
|
||
|
|
}
|
||
|
|
seen[section.Name] = true
|
||
|
|
content, err := json.MarshalIndent(section.Value, "", " ")
|
||
|
|
if err != nil {
|
||
|
|
return nil, nil, fmt.Errorf("encode support bundle section %q: %w", section.Name, err)
|
||
|
|
}
|
||
|
|
content = append(content, '\n')
|
||
|
|
if len(content) > m.maxSection {
|
||
|
|
return nil, nil, fmt.Errorf("support bundle section %q exceeds the configured limit", section.Name)
|
||
|
|
}
|
||
|
|
totalSectionBytes += len(content)
|
||
|
|
if totalSectionBytes > m.maxBytes {
|
||
|
|
return nil, nil, errors.New("support bundle sections exceed the configured aggregate limit")
|
||
|
|
}
|
||
|
|
sectionNames = append(sectionNames, section.Name)
|
||
|
|
sectionContent[section.Name] = content
|
||
|
|
}
|
||
|
|
sort.Strings(sectionNames)
|
||
|
|
manifest := struct {
|
||
|
|
FormatVersion int `json:"format_version"`
|
||
|
|
BundleID string `json:"bundle_id"`
|
||
|
|
GeneratedAt time.Time `json:"generated_at"`
|
||
|
|
Sections []string `json:"sections"`
|
||
|
|
}{FormatVersion: 1, BundleID: request.BundleID, GeneratedAt: request.GeneratedAt.UTC(), Sections: sectionNames}
|
||
|
|
manifestContent, err := json.MarshalIndent(manifest, "", " ")
|
||
|
|
if err != nil {
|
||
|
|
return nil, nil, err
|
||
|
|
}
|
||
|
|
manifestContent = append(manifestContent, '\n')
|
||
|
|
|
||
|
|
var buffer bytes.Buffer
|
||
|
|
archive := zip.NewWriter(&buffer)
|
||
|
|
if err := writeZipEntry(archive, "manifest.json", manifestContent, request.GeneratedAt); err != nil {
|
||
|
|
return nil, nil, err
|
||
|
|
}
|
||
|
|
for _, name := range sectionNames {
|
||
|
|
if err := writeZipEntry(archive, name, sectionContent[name], request.GeneratedAt); err != nil {
|
||
|
|
return nil, nil, err
|
||
|
|
}
|
||
|
|
if buffer.Len() > m.maxBytes {
|
||
|
|
_ = archive.Close()
|
||
|
|
return nil, nil, errors.New("support bundle artifact exceeds the configured limit")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := archive.Close(); err != nil {
|
||
|
|
return nil, nil, fmt.Errorf("finalize support bundle: %w", err)
|
||
|
|
}
|
||
|
|
if buffer.Len() > m.maxBytes {
|
||
|
|
return nil, nil, errors.New("support bundle artifact exceeds the configured limit")
|
||
|
|
}
|
||
|
|
return buffer.Bytes(), sectionNames, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeZipEntry(archive *zip.Writer, name string, content []byte, modified time.Time) error {
|
||
|
|
header := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||
|
|
header.SetMode(0o600)
|
||
|
|
header.SetModTime(modified.UTC())
|
||
|
|
entry, err := archive.CreateHeader(header)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("create support bundle entry %q: %w", name, err)
|
||
|
|
}
|
||
|
|
if _, err := entry.Write(content); err != nil {
|
||
|
|
return fmt.Errorf("write support bundle entry %q: %w", name, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) trimLocked() error {
|
||
|
|
entries, err := os.ReadDir(m.directory)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("list support bundles: %w", err)
|
||
|
|
}
|
||
|
|
names := make([]string, 0, len(entries))
|
||
|
|
for _, entry := range entries {
|
||
|
|
name := entry.Name()
|
||
|
|
if entry.Type().IsRegular() && strings.HasPrefix(name, "support_bundle_") && strings.HasSuffix(name, ".zip") {
|
||
|
|
names = append(names, name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Strings(names)
|
||
|
|
for len(names) > m.maxArtifacts {
|
||
|
|
if err := os.Remove(filepath.Join(m.directory, names[0])); err != nil {
|
||
|
|
return fmt.Errorf("remove expired support bundle: %w", err)
|
||
|
|
}
|
||
|
|
names = names[1:]
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func ensureDirectory(directory string) error {
|
||
|
|
if info, err := os.Lstat(directory); err == nil {
|
||
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||
|
|
return errors.New("support bundle directory must be a real directory")
|
||
|
|
}
|
||
|
|
} else if os.IsNotExist(err) {
|
||
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||
|
|
return fmt.Errorf("create support bundle directory: %w", err)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
return fmt.Errorf("inspect support bundle directory: %w", err)
|
||
|
|
}
|
||
|
|
if err := os.Chmod(directory, 0o700); err != nil {
|
||
|
|
return fmt.Errorf("protect support bundle directory: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateBundleID(value string) error {
|
||
|
|
if len(value) != len("support_bundle_20060102_000000") || !strings.HasPrefix(value, "support_bundle_") {
|
||
|
|
return errors.New("support bundle id is invalid")
|
||
|
|
}
|
||
|
|
date := value[len("support_bundle_") : len("support_bundle_")+8]
|
||
|
|
if _, err := time.Parse("20060102", date); err != nil {
|
||
|
|
return errors.New("support bundle id is invalid")
|
||
|
|
}
|
||
|
|
if value[len("support_bundle_")+8] != '_' {
|
||
|
|
return errors.New("support bundle id is invalid")
|
||
|
|
}
|
||
|
|
for _, char := range value[len(value)-6:] {
|
||
|
|
if char < '0' || char > '9' {
|
||
|
|
return errors.New("support bundle id is invalid")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateSectionName(value string) error {
|
||
|
|
if len(value) < 6 || len(value) > 64 || !strings.HasSuffix(value, ".json") || strings.ContainsAny(value, `/\\`) {
|
||
|
|
return errors.New("support bundle section name is invalid")
|
||
|
|
}
|
||
|
|
for _, char := range strings.TrimSuffix(value, ".json") {
|
||
|
|
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
return errors.New("support bundle section name is invalid")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|