Files
nmon/internal/backup/backup.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

195 lines
6.1 KiB
Go

package backup
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/store"
)
// DefaultSettle is how far behind "now" the consistent boundary sits: the write queue holds samples for up
// to 60 s, so anything older than this has reached the stores before the snapshots are taken.
const DefaultSettle = 90 * time.Second
// Options configures Backup.
type Options struct {
// DataDir is nmon's data directory; scratch files live inside it, never elsewhere.
DataDir string
// KeyFile is the encryption key file; it is archived only when IncludeKey is set.
KeyFile string
IncludeKey bool
// OutPath is the archive to create. It is written as a temp file beside it and renamed.
OutPath string
Store store.Store
// Stores are the metric stores to snapshot (see StoresFromStatus).
Stores []VMStore
// ReserveCheck refuses the backup by returning an error when disk is below the 5% reserve.
ReserveCheck func() error
Clock clock.Clock
// Log receives the key notice and progress lines; nil discards them.
Log io.Writer
// Settle overrides DefaultSettle.
Settle time.Duration
// HTTP is used for the stores' snapshot API; nil takes a client with a 5 minute timeout.
HTTP *http.Client
}
// Result describes a finished backup.
type Result struct {
Path string
Bytes int64
KeyIncluded bool
Manifest *Manifest
}
// ErrReserve wraps the ReserveCheck error when a backup is refused for lack of disk.
var ErrReserve = errors.New("backup refused: disk below the reserve")
// KeyNotice is the line every backup prints about the key file.
func KeyNotice(included bool) string {
if included {
return "backup: encryption key file INCLUDED in this archive; protect the archive like the key itself"
}
return "backup: encryption key file NOT included; keep your key file safe, a restore without it has no SNMP credentials"
}
func (o *Options) defaults() error {
switch {
case o.DataDir == "" || o.OutPath == "" || o.Store == nil:
return errors.New("backup: DataDir, OutPath and Store are required")
case o.IncludeKey && o.KeyFile == "":
return errors.New("backup: IncludeKey needs KeyFile")
}
if o.Clock == nil {
o.Clock = clock.Real()
}
if o.Log == nil {
o.Log = io.Discard
}
if o.Settle <= 0 {
o.Settle = DefaultSettle
}
if o.HTTP == nil {
o.HTTP = &http.Client{Timeout: 5 * time.Minute}
}
return nil
}
// Backup writes one archive to o.OutPath. On any failure nothing is left at OutPath.
func Backup(ctx context.Context, o Options) (res *Result, err error) {
if err := o.defaults(); err != nil {
return nil, err
}
logf(o.Log, "%s\n", KeyNotice(o.IncludeKey))
if o.ReserveCheck != nil {
if err := o.ReserveCheck(); err != nil {
return nil, fmt.Errorf("%w: %w", ErrReserve, err)
}
}
scratch, err := os.MkdirTemp(o.DataDir, ".backup-")
if err != nil {
return nil, fmt.Errorf("backup: scratch dir: %w", err)
}
defer func() { err = errors.Join(err, os.RemoveAll(scratch)) }()
out, err := os.CreateTemp(filepath.Dir(o.OutPath), ".nmon-backup-*")
if err != nil {
return nil, fmt.Errorf("backup: create output: %w", err)
}
defer func() {
if err != nil {
_ = out.Close() // best effort: the original error is the one to report
_ = os.Remove(out.Name()) // a failed backup leaves no partial file
}
}()
m, err := writeArchive(ctx, &o, out, scratch)
if err != nil {
return nil, err
}
size, err := commitFile(out, o.OutPath)
if err != nil {
return nil, err
}
logf(o.Log, "backup: wrote %s (%d bytes)\n%s\n", o.OutPath, size, KeyNotice(o.IncludeKey))
return &Result{Path: o.OutPath, Bytes: size, KeyIncluded: o.IncludeKey, Manifest: m}, nil
}
// commitFile syncs f, renames it to dst and syncs the directory so the rename survives power loss.
func commitFile(f *os.File, dst string) (int64, error) {
if err := f.Sync(); err != nil {
return 0, fmt.Errorf("backup: sync: %w", err)
}
st, err := f.Stat()
if err != nil {
return 0, err
}
if err := f.Close(); err != nil {
return 0, err
}
if err := os.Chmod(f.Name(), 0o600); err != nil {
return 0, err
}
if err := os.Rename(f.Name(), dst); err != nil {
return 0, fmt.Errorf("backup: rename into place: %w", err)
}
if d, err := os.Open(filepath.Dir(dst)); err == nil {
_ = d.Sync() // best effort: some filesystems cannot sync a directory
_ = d.Close()
}
return st.Size(), nil
}
// writeArchive runs the backup steps in order: inventory, metric stores, config, key, manifest.
func writeArchive(ctx context.Context, o *Options, out io.Writer, scratch string) (*Manifest, error) {
now := o.Clock.Now().UTC()
m := &Manifest{Format: manifestFormat, CreatedAt: now, Boundary: now.Add(-o.Settle).Truncate(time.Second), KeyIncluded: o.IncludeKey}
aw := newArchiveWriter(out, now)
steps := []func() error{
func() error { return addMetricStores(ctx, o, aw, m) },
func() error { return addInventory(ctx, o, aw, m, scratch) },
func() error { return addConfig(o, aw) },
func() error { return addKey(o, aw) },
}
for _, step := range steps {
if err := step(); err != nil {
return nil, err
}
}
return m, aw.finish(m)
}
func addConfig(o *Options, aw *archiveWriter) error {
if _, err := os.Stat(config.File(o.DataDir)); err == nil {
if err := aw.addFile(configEntry, config.File(o.DataDir)); err != nil {
return err
}
}
if _, err := os.Stat(config.ProfilesDir(o.DataDir)); err == nil {
if err := aw.addTree(profilesPrefix[:len(profilesPrefix)-1], config.ProfilesDir(o.DataDir)); err != nil {
return err
}
}
// The IEEE vendor registry is reference data kept beside the inventory, not in it; a restore keeps it.
if _, err := os.Stat(config.OUIDir(o.DataDir)); err == nil {
return aw.addTree(ouiPrefix[:len(ouiPrefix)-1], config.OUIDir(o.DataDir))
}
return nil
}
func addKey(o *Options, aw *archiveWriter) error {
if !o.IncludeKey {
return nil
}
return aw.addFile(keyEntry, o.KeyFile)
}
// logf writes a progress line; a broken log stream must not fail a backup.
func logf(w io.Writer, format string, args ...any) { _, _ = fmt.Fprintf(w, format, args...) }