Files
beacon-server/internal/backup/export.go
T
MrAlders0n 041d9c1f45 release: v2.0.2
Release the changes on `dev` as v2.0.2: authenticated MeshMapper imports and the traffic-sorted observer directory, including rolling seven-day counts from available history. Bump the API version and regenerate Swagger.

MeshMapper integration requires an API key. Regional admins’ existing per-region or grouped-region coverage keys also work for these APIs. Deployments should set `MESHMAPPER_API_KEY` and restart Beacon.

Validation: `go build ./...`, `gofmt -l .` (empty), `go vet ./...`, `go test ./...`, `govulncheck ./...`, and race tests for MeshMapper and scopestore passed in a clean checkout. PostgreSQL integration tests require CI because Docker is unavailable locally.
2026-10-05 18:35:41 -04:00

276 lines
9.2 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package backup exports a database and saved configuration into a private bundle.
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
DefaultMaxBytes = int64(1 << 30)
DefaultTimeout = 10 * time.Minute
maxConfigBytes = 1 << 20
)
var ErrTooLarge = errors.New("backup size limit exceeded")
// Options selects local files and finite resource limits. Connection settings
// come from libpq's PG* environment; PGDATABASE must explicitly name the database.
type Options struct {
ConfigPath string
OutputPath string
MaxBytes int64 // Maximum uncompressed database dump size.
Timeout time.Duration
Version string
// ConnectionService overrides PG* using a private service file. Construct it
// with ConnectionService at startup; empty retains the standalone PG* mode.
ConnectionService string
}
// Manifest describes format 1; hashes cover the uncompressed archive members.
type Manifest struct {
FormatVersion int `json:"format_version"`
CreatedAt time.Time `json:"created_at"`
ToolVersion string `json:"tool_version"`
DatabaseFormat string `json:"database_format"`
Files []File `json:"files"`
Excluded []string `json:"excluded"`
}
// File identifies one payload by its fixed archive name, size and SHA-256.
type File struct {
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
// Export creates a complete .tar.gz without replacing an existing destination.
// pg_dump must be installed on PATH. The destination filesystem must support
// hard links; a link publishes the finished private file without an overwrite race.
// The bundle contains secrets if they are present in the database or saved YAML.
func Export(ctx context.Context, opts Options) error {
return export(ctx, opts, dumpCommand)
}
func dumpCommand(ctx context.Context) *exec.Cmd {
return exec.CommandContext(ctx, "pg_dump", "--format=plain", "--no-owner",
"--no-acl", "--no-tablespaces", "--no-password", "--lock-wait-timeout=5000")
}
func export(ctx context.Context, opts Options, command func(context.Context) *exec.Cmd) (exportErr error) {
if opts.ConfigPath == "" || opts.OutputPath == "" || opts.MaxBytes <= 0 || opts.MaxBytes > 1<<40 || opts.Timeout <= 0 {
return errors.New("config, output, positive timeout and max-bytes (at most 1 TiB) are required")
}
if opts.ConnectionService == "" && os.Getenv("PGDATABASE") == "" {
return errors.New("PGDATABASE must explicitly name the database; POSTGRES_DSN is not used")
}
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
if err := ctx.Err(); err != nil {
return err
}
if _, err := os.Lstat(opts.OutputPath); !errors.Is(err, os.ErrNotExist) {
return errors.New("output already exists or cannot be inspected")
}
config, err := readConfig(opts.ConfigPath)
if err != nil {
return err
}
parent, err := filepath.Abs(filepath.Dir(opts.OutputPath))
if err != nil {
return errors.New("invalid output directory")
}
temp, err := os.MkdirTemp(parent, ".beacon-backup-")
if err != nil {
return errors.New("cannot create private backup staging directory")
}
defer os.RemoveAll(temp)
dump, err := os.OpenFile(filepath.Join(temp, "database.sql"), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return errors.New("cannot create private database dump")
}
defer closeBackupFile(dump, &exportErr)
hash := sha256.New()
output := &limitedWriter{ctx: ctx, cancel: cancel, dst: io.MultiWriter(dump, hash), remaining: opts.MaxBytes}
cmd := command(ctx)
if opts.ConnectionService != "" {
servicePath := filepath.Join(temp, "pg_service.conf")
if err := os.WriteFile(servicePath, []byte(opts.ConnectionService), 0600); err != nil {
return errors.New("cannot prepare private backup connection")
}
// Do not let unrelated PG* values redirect the client, and never mutate
// the Beacon process environment while another request is running.
cmd.Env = nil
for _, entry := range os.Environ() {
if !strings.HasPrefix(strings.ToUpper(entry), "PG") {
cmd.Env = append(cmd.Env, entry)
}
}
cmd.Env = append(cmd.Env, "PGSERVICEFILE="+servicePath, "PGSERVICE=beacon_backup")
}
cmd.Stdout, cmd.Stderr = output, io.Discard
cmd.WaitDelay = time.Second
err = cmd.Run()
if output.err != nil {
return output.err
}
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
// Client diagnostics may contain credentials, hostnames or database contents.
return errors.New("pg_dump failed; check the installed client and private connection settings")
}
size := opts.MaxBytes - output.remaining
if size == 0 {
return errors.New("pg_dump produced an empty dump")
}
if _, err = dump.Seek(0, io.SeekStart); err != nil {
return errors.New("cannot read completed database dump")
}
configHash := sha256.Sum256(config)
manifest := Manifest{
FormatVersion: 1, CreatedAt: time.Now().UTC(), ToolVersion: opts.Version,
DatabaseFormat: "postgresql-plain-sql",
Files: []File{
{Name: "database.sql", Size: size, SHA256: hex.EncodeToString(hash.Sum(nil))},
{Name: "config.yaml", Size: int64(len(config)), SHA256: hex.EncodeToString(configHash[:])},
},
Excluded: []string{"deployment_environment", "external_config_files", "runtime_only_changes", "roles_ownership_acl_tablespaces", "redis_cache"},
}
metadata, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return errors.New("cannot encode backup manifest")
}
archivePath := filepath.Join(temp, "bundle.tar.gz")
archive, err := os.OpenFile(archivePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return errors.New("cannot create private backup archive")
}
defer closeBackupFile(archive, &exportErr)
// Input is bounded; reserve room for YAML, tar headers and compression overhead.
archiveOutput := &limitedWriter{ctx: ctx, cancel: cancel, dst: archive, remaining: opts.MaxBytes + opts.MaxBytes/100 + 2*maxConfigBytes}
gz := gzip.NewWriter(archiveOutput)
tw := tar.NewWriter(gz)
for _, member := range []struct {
name string
size int64
data io.Reader
}{
{"manifest.json", int64(len(metadata)), bytes.NewReader(metadata)},
{"database.sql", size, dump},
{"config.yaml", int64(len(config)), bytes.NewReader(config)},
} {
if err = tw.WriteHeader(&tar.Header{Name: member.name, Size: member.size, Mode: 0600, ModTime: manifest.CreatedAt}); err == nil {
_, err = io.Copy(tw, member.data)
}
if err != nil {
return errors.New("cannot write complete backup archive")
}
}
if err = dump.Close(); err != nil {
return errors.New("cannot close completed database dump")
}
if err = tw.Close(); err != nil {
return errors.New("cannot complete backup tar")
}
if err = gz.Close(); err != nil {
return errors.New("cannot complete backup compression")
}
if err = archive.Sync(); err != nil {
return errors.New("cannot sync completed backup")
}
if err = archive.Close(); err != nil {
return errors.New("cannot close completed backup")
}
if err = ctx.Err(); err != nil {
return err
}
if err = os.Link(archivePath, opts.OutputPath); err != nil {
return errors.New("cannot publish backup without overwriting; check destination and hard-link support")
}
return nil
}
// Cleanup also checks close errors. Both writable files are explicitly closed
// before publication; a deferred second close may therefore report ErrClosed.
func closeBackupFile(file *os.File, exportErr *error) {
if err := file.Close(); err != nil && !errors.Is(err, os.ErrClosed) && *exportErr == nil {
*exportErr = errors.New("cannot close private backup file")
}
}
func readConfig(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() > maxConfigBytes {
return nil, errors.New("config must be a readable regular file of at most 1 MiB")
}
f, err := os.Open(path)
if err != nil {
return nil, errors.New("cannot read saved config")
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, maxConfigBytes+1))
if err != nil || len(data) > maxConfigBytes {
return nil, errors.New("cannot read saved config within the 1 MiB limit")
}
var credentials struct {
MeshMapper struct {
APIKey string `yaml:"api_key"`
} `yaml:"meshmapper"`
}
if err := yaml.Unmarshal(data, &credentials); err != nil {
return nil, errors.New("cannot inspect saved config for MeshMapper credentials")
}
// Backups preserve YAML verbatim, so require the deployment secret outside it.
if credentials.MeshMapper.APIKey != "" {
return nil, errors.New("backup unavailable: move meshmapper.api_key to MESHMAPPER_API_KEY and clear the saved YAML value")
}
return data, nil
}
type limitedWriter struct {
ctx context.Context
cancel context.CancelFunc
dst io.Writer
remaining int64
err error
}
func (w *limitedWriter) Write(p []byte) (n int, err error) {
if w.err == nil {
w.err = w.ctx.Err()
}
if w.err == nil && int64(len(p)) > w.remaining {
w.err = ErrTooLarge
}
if w.err == nil {
n, w.err = w.dst.Write(p)
w.remaining -= int64(n)
if w.err == nil && n != len(p) {
w.err = io.ErrShortWrite
}
}
if w.err != nil {
w.cancel()
}
return n, w.err
}