Files
nmon/internal/backup/restore.go
Vibe Coder c4d14a67d1 Add the optional IEEE vendor registry for MAC addresses and the l2/oui settings
The owner asked to see the vendor of each MAC where the IEEE registry can be
fetched, and never to depend on it. internal/oui downloads MA-L, MA-M and MA-S
weekly through the outbound allow-list (timeouts, a size cap and a row-count
sanity check, so a truncated or wrong download keeps the previous file), stores
one checksummed, atomically written file in <data-dir>/oui that nmon backup
includes, and looks MACs up by the longest prefix. A missing registry only
leaves the vendor empty. `nmon oui import DIR|FILE` loads the CSVs offline, and
the Ansible role can ship them next to the MIB sidecar.

internal/hostaddr parses the MAC, prefix, IP and CIDR forms the lookup accepts
and classifies a MAC as global, local or multicast. config gains the l2 and oui
sections (validated here; the L2 job that reads l2 follows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 01:30:11 +00:00

273 lines
8.2 KiB
Go

package backup
import (
"archive/tar"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/store"
)
// Restore errors.
var (
// ErrNotEmpty means the target data directory already holds files; Restore never merges.
ErrNotEmpty = errors.New("restore: data directory is not empty")
// ErrTruncated means the archive ended early or does not match its manifest.
ErrTruncated = errors.New("restore: archive is truncated or damaged")
// ErrVerify means the restored data does not match the manifest.
ErrVerify = errors.New("restore: verification failed")
)
const stagingDir = ".restore-staging"
// RestoreOptions configures Restore.
type RestoreOptions struct {
// File is the archive; DataDir the clean directory to restore into (absent or empty).
File, DataDir string
// KeyFile is where an included key file is written (mode 0600); empty means <DataDir>/secret.key.
KeyFile string
// Store receives a JSON-lines inventory (Postgres/MySQL archives): an open, migrated, empty database.
// SQLite archives restore to <DataDir>/inventory.db and ignore it.
Store store.Store
// Verifier checks the restored metric stores' sampled series; nil uses NewVMVerifier with defaults.
Verifier SeriesVerifier
Clock clock.Clock
Log io.Writer
}
// RestoreResult reports what was restored and verified.
type RestoreResult struct {
Manifest *Manifest
// KeyRestoredTo is empty when the archive had no key.
KeyRestoredTo string
// RowsVerified and SeriesVerified count what matched the manifest.
RowsVerified int64
SeriesVerified int
}
// Restore unpacks file into a clean data directory and verifies the result before returning success.
// Any failure removes what it created, so a retry starts clean.
func Restore(ctx context.Context, o RestoreOptions) (res *RestoreResult, err error) {
if o.Clock == nil {
o.Clock = clock.Real()
}
if o.Log == nil {
o.Log = io.Discard
}
if err := requireClean(o.DataDir); err != nil {
return nil, err
}
defer func() {
if err != nil {
err = errors.Join(err, emptyDir(o.DataDir))
}
}()
if err := os.MkdirAll(filepath.Join(o.DataDir, stagingDir), 0o700); err != nil {
return nil, err
}
m, err := extract(o.File, filepath.Join(o.DataDir, stagingDir))
if err != nil {
return nil, err
}
logf(o.Log, "restore: archive complete (%d files, backup of %s, key %s)\n", len(m.Files),
m.CreatedAt.Format(time.RFC3339), map[bool]string{true: "included", false: "not included"}[m.KeyIncluded])
res = &RestoreResult{Manifest: m}
if err := place(&o, m, res); err != nil {
return nil, err
}
if err := verify(ctx, &o, m, res); err != nil {
return nil, fmt.Errorf("%w: %w", ErrVerify, err)
}
logf(o.Log, "restore: verified %d rows and %d series\n", res.RowsVerified, res.SeriesVerified)
return res, os.RemoveAll(filepath.Join(o.DataDir, stagingDir))
}
func requireClean(dir string) error {
ents, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return os.MkdirAll(dir, 0o700)
}
if err != nil {
return err
}
if len(ents) > 0 {
return fmt.Errorf("%w: %s", ErrNotEmpty, dir)
}
return nil
}
// emptyDir removes the children of dir (which Restore verified empty on entry).
func emptyDir(dir string) error {
ents, err := os.ReadDir(dir)
if err != nil {
return err
}
var errs []error
for _, e := range ents {
errs = append(errs, os.RemoveAll(filepath.Join(dir, e.Name())))
}
return errors.Join(errs...)
}
// extract unpacks every entry into staging, hashing as it goes, then proves the archive is whole:
// the last entry is the manifest and every other entry matches it.
func extract(file, staging string) (*Manifest, error) {
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }() // read-only handle
tr := tar.NewReader(f)
seen := map[string]FileEntry{}
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil, fmt.Errorf("%w: no manifest", ErrTruncated)
}
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrTruncated, err)
}
if hdr.Name == manifestName {
return finishExtract(tr, f, seen)
}
fe, err := extractEntry(tr, hdr, staging)
if err != nil {
return nil, err
}
seen[fe.Name] = fe
}
}
func finishExtract(tr *tar.Reader, f *os.File, seen map[string]FileEntry) (*Manifest, error) {
b, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("%w: manifest: %w", ErrTruncated, err)
}
var m Manifest
if err := json.Unmarshal(b, &m); err != nil || m.Format != manifestFormat {
return nil, fmt.Errorf("%w: unreadable manifest (format %d): %w", ErrTruncated, m.Format, err)
}
if _, err := tr.Next(); !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("%w: data after the manifest", ErrTruncated)
}
if err := requireTrailer(f); err != nil {
return nil, err
}
if len(m.Files) != len(seen) {
return nil, fmt.Errorf("%w: manifest lists %d files, archive has %d", ErrTruncated, len(m.Files), len(seen))
}
for _, want := range m.Files {
if got, ok := seen[want.Name]; !ok || got != want {
return nil, fmt.Errorf("%w: entry %s does not match the manifest", ErrTruncated, want.Name)
}
}
return &m, nil
}
func extractEntry(tr *tar.Reader, hdr *tar.Header, staging string) (FileEntry, error) {
name := path.Clean(hdr.Name)
if hdr.Typeflag != tar.TypeReg || strings.HasPrefix(name, "../") || name == ".." || path.IsAbs(name) {
return FileEntry{}, fmt.Errorf("%w: unsafe entry %q", ErrTruncated, hdr.Name)
}
dst := filepath.Join(staging, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return FileEntry{}, err
}
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return FileEntry{}, err
}
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), tr)
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return FileEntry{}, fmt.Errorf("%w: %s: %w", ErrTruncated, name, err)
}
return FileEntry{Name: name, Size: n, SHA256: hex.EncodeToString(h.Sum(nil))}, nil
}
// place moves the staged entries to their final homes.
func place(o *RestoreOptions, m *Manifest, res *RestoreResult) error {
stg := filepath.Join(o.DataDir, stagingDir)
moves := map[string]string{
configEntry: config.File(o.DataDir),
strings.TrimSuffix(profilesPrefix, "/"): config.ProfilesDir(o.DataDir),
strings.TrimSuffix(ouiPrefix, "/"): config.OUIDir(o.DataDir),
"hot": config.HotDir(o.DataDir),
"long": config.LongDir(o.DataDir),
}
if m.Inventory == inventoryDB {
moves[inventoryDB] = config.InventoryDB(o.DataDir)
}
for src, dst := range moves {
if _, err := os.Stat(filepath.Join(stg, src)); err != nil {
continue
}
if err := os.Rename(filepath.Join(stg, src), dst); err != nil {
return fmt.Errorf("restore: place %s: %w", src, err)
}
}
if m.KeyIncluded {
kf := o.KeyFile
if kf == "" {
kf = filepath.Join(o.DataDir, keyEntry)
}
if err := moveFile(filepath.Join(stg, keyEntry), kf); err != nil {
return fmt.Errorf("restore: place key file: %w", err)
}
res.KeyRestoredTo = kf
}
return nil
}
// moveFile renames src to dst, copying when they are on different filesystems (the key file usually is).
func moveFile(src, dst string) error {
if err := os.Rename(src, dst); err == nil {
return nil
}
b, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return err
}
return os.WriteFile(dst, b, 0o600)
}
// requireTrailer checks the archive ends with tar's two zero blocks, so a copy cut anywhere (even just
// after the manifest) is refused.
func requireTrailer(f *os.File) error {
st, err := f.Stat()
if err != nil {
return err
}
if st.Size()%512 != 0 || st.Size() < 1024 {
return fmt.Errorf("%w: size %d is not a whole number of tar blocks", ErrTruncated, st.Size())
}
tail := make([]byte, 1024)
if _, err := f.ReadAt(tail, st.Size()-1024); err != nil {
return fmt.Errorf("%w: %w", ErrTruncated, err)
}
for _, b := range tail {
if b != 0 {
return fmt.Errorf("%w: missing end-of-archive marker", ErrTruncated)
}
}
return nil
}