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
195 lines
6.1 KiB
Go
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...) }
|