mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-15 20:29:54 +00:00
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.
276 lines
9.2 KiB
Go
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
|
|
}
|