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.

362 lines
11 KiB

// 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
}
// Reset deletes diagnostic archives after producers have been stopped.
func (m *Manager) Reset() error {
m.mu.Lock()
defer m.mu.Unlock()
entries, err := os.ReadDir(m.directory)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
return errors.New("unexpected directory in support bundle store")
}
if err := os.Remove(filepath.Join(m.directory, entry.Name())); err != nil {
return err
}
}
return nil
}