chore: publish pxmon v0.2.0
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
exportMagic = "OBSCTLEXP1:"
|
||||
exportVersion = 2
|
||||
saltBytes = 16
|
||||
scryptN = 1 << 15
|
||||
scryptR = 8
|
||||
scryptP = 1
|
||||
)
|
||||
|
||||
// ExportBundle is the portable payload produced by Service.Export. It is
|
||||
// self-contained: it includes every cluster (with stored credentials),
|
||||
// telegram bot settings, locker config, and alert policies.
|
||||
type ExportBundle struct {
|
||||
Version int `json:"version"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
Registry Registry `json:"registry"`
|
||||
Files []ExportedFile `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
type ExportedFile struct {
|
||||
OriginalPath string `json:"original_path"`
|
||||
Kind string `json:"kind"`
|
||||
Content []byte `json:"content"`
|
||||
}
|
||||
|
||||
// ImportMode controls how an imported bundle is merged into the current store.
|
||||
type ImportMode string
|
||||
|
||||
const (
|
||||
// ImportModeMerge adds clusters from the bundle; existing clusters with
|
||||
// the same name are replaced with the imported copy.
|
||||
ImportModeMerge ImportMode = "merge"
|
||||
// ImportModeReplace wipes the current registry and replaces it with the
|
||||
// imported bundle as-is.
|
||||
ImportModeReplace ImportMode = "replace"
|
||||
)
|
||||
|
||||
// ImportReport summarizes what happened during an import.
|
||||
type ImportReport struct {
|
||||
Added int
|
||||
Replaced int
|
||||
TotalAfter int
|
||||
TelegramApplied bool
|
||||
LockerApplied bool
|
||||
Mode ImportMode
|
||||
}
|
||||
|
||||
// Export writes an encrypted, passphrase-protected bundle of the full
|
||||
// registry to outPath. The bundle can be imported on another machine with
|
||||
// the same password — no master key transfer required.
|
||||
func (s *Service) Export(outPath, password string) error {
|
||||
if strings.TrimSpace(outPath) == "" {
|
||||
return errors.New("export: output path is required")
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return errors.New("export: password is required")
|
||||
}
|
||||
|
||||
reg, err := s.store.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: load registry: %w", err)
|
||||
}
|
||||
|
||||
files, err := collectExportFiles(reg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bundle := ExportBundle{
|
||||
Version: exportVersion,
|
||||
ExportedAt: s.now().UTC().Format(time.RFC3339),
|
||||
Registry: reg,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(bundle, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: encode bundle: %w", err)
|
||||
}
|
||||
|
||||
salt := make([]byte, saltBytes)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return fmt.Errorf("export: generate salt: %w", err)
|
||||
}
|
||||
|
||||
key, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, masterKeyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: derive key: %w", err)
|
||||
}
|
||||
|
||||
ciphertext, err := encrypt(payload, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: encrypt: %w", err)
|
||||
}
|
||||
|
||||
blob := append([]byte{}, salt...)
|
||||
blob = append(blob, ciphertext...)
|
||||
encoded := exportMagic + base64.StdEncoding.EncodeToString(blob) + "\n"
|
||||
|
||||
if dir := filepath.Dir(outPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("export: create output dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tmp := outPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(encoded), 0o600); err != nil {
|
||||
return fmt.Errorf("export: write temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, outPath); err != nil {
|
||||
return fmt.Errorf("export: replace output: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Import reads an exported bundle from inPath using password, and applies
|
||||
// it to the local registry according to mode.
|
||||
func (s *Service) Import(inPath, password string, mode ImportMode) (ImportReport, error) {
|
||||
if strings.TrimSpace(inPath) == "" {
|
||||
return ImportReport{}, errors.New("import: input path is required")
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return ImportReport{}, errors.New("import: password is required")
|
||||
}
|
||||
if mode == "" {
|
||||
mode = ImportModeMerge
|
||||
}
|
||||
if mode != ImportModeMerge && mode != ImportModeReplace {
|
||||
return ImportReport{}, fmt.Errorf("import: unsupported mode %q", mode)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(inPath)
|
||||
if err != nil {
|
||||
return ImportReport{}, fmt.Errorf("import: read input: %w", err)
|
||||
}
|
||||
text := strings.TrimSpace(string(raw))
|
||||
if !strings.HasPrefix(text, exportMagic) {
|
||||
return ImportReport{}, errors.New("import: not a PXmon export bundle")
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(text, exportMagic))
|
||||
if err != nil {
|
||||
return ImportReport{}, fmt.Errorf("import: decode payload: %w", err)
|
||||
}
|
||||
if len(blob) < saltBytes+aes.BlockSize {
|
||||
return ImportReport{}, errors.New("import: payload truncated")
|
||||
}
|
||||
salt := blob[:saltBytes]
|
||||
ciphertext := blob[saltBytes:]
|
||||
|
||||
key, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, masterKeyBytes)
|
||||
if err != nil {
|
||||
return ImportReport{}, fmt.Errorf("import: derive key: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := decrypt(ciphertext, key)
|
||||
if err != nil {
|
||||
return ImportReport{}, errors.New("import: wrong password or corrupt bundle")
|
||||
}
|
||||
|
||||
var bundle ExportBundle
|
||||
if err := json.Unmarshal(plaintext, &bundle); err != nil {
|
||||
return ImportReport{}, fmt.Errorf("import: parse bundle: %w", err)
|
||||
}
|
||||
if bundle.Version == 0 || bundle.Version > exportVersion {
|
||||
return ImportReport{}, fmt.Errorf("import: unsupported bundle version %d", bundle.Version)
|
||||
}
|
||||
|
||||
incoming := bundle.Registry
|
||||
if incoming.Clusters == nil {
|
||||
incoming.Clusters = []Cluster{}
|
||||
}
|
||||
if len(bundle.Files) > 0 {
|
||||
restored, restoreErr := s.restoreExportFiles(bundle.Files)
|
||||
if restoreErr != nil {
|
||||
return ImportReport{}, restoreErr
|
||||
}
|
||||
applyRestoredFilePaths(&incoming, restored)
|
||||
}
|
||||
|
||||
report := ImportReport{Mode: mode}
|
||||
|
||||
if mode == ImportModeReplace {
|
||||
report.Added = len(incoming.Clusters)
|
||||
report.TotalAfter = len(incoming.Clusters)
|
||||
report.TelegramApplied = incoming.Telegram.Token != "" || incoming.Telegram.Enabled || len(incoming.Telegram.AllowedUserIDs) > 0
|
||||
report.LockerApplied = incoming.Locker.PasswordHash != "" || incoming.Locker.Enabled
|
||||
if err := s.store.Save(incoming); err != nil {
|
||||
return ImportReport{}, fmt.Errorf("import: save: %w", err)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
current, err := s.store.Load()
|
||||
if err != nil {
|
||||
return ImportReport{}, fmt.Errorf("import: load current: %w", err)
|
||||
}
|
||||
if current.Clusters == nil {
|
||||
current.Clusters = []Cluster{}
|
||||
}
|
||||
|
||||
existingByName := make(map[string]int, len(current.Clusters))
|
||||
for i, c := range current.Clusters {
|
||||
existingByName[strings.ToLower(c.Name)] = i
|
||||
}
|
||||
|
||||
for _, inc := range incoming.Clusters {
|
||||
key := strings.ToLower(strings.TrimSpace(inc.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
inc.Alerts = ensureAlertPolicy(inc.Alerts)
|
||||
if idx, ok := existingByName[key]; ok {
|
||||
// Preserve original ID to keep references stable.
|
||||
inc.ID = current.Clusters[idx].ID
|
||||
current.Clusters[idx] = inc
|
||||
report.Replaced++
|
||||
} else {
|
||||
if strings.TrimSpace(inc.ID) == "" {
|
||||
inc.ID = newClusterID()
|
||||
}
|
||||
current.Clusters = append(current.Clusters, inc)
|
||||
existingByName[key] = len(current.Clusters) - 1
|
||||
report.Added++
|
||||
}
|
||||
}
|
||||
|
||||
if incoming.Telegram.Token != "" || incoming.Telegram.Enabled || len(incoming.Telegram.AllowedUserIDs) > 0 {
|
||||
current.Telegram = incoming.Telegram
|
||||
report.TelegramApplied = true
|
||||
}
|
||||
if incoming.Locker.PasswordHash != "" || incoming.Locker.Enabled {
|
||||
current.Locker = incoming.Locker
|
||||
report.LockerApplied = true
|
||||
}
|
||||
current.Backups = mergeBackupConfig(current.Backups, incoming.Backups)
|
||||
if strings.TrimSpace(current.ActiveClusterID) == "" {
|
||||
current.ActiveClusterID = incoming.ActiveClusterID
|
||||
}
|
||||
|
||||
report.TotalAfter = len(current.Clusters)
|
||||
if err := s.store.Save(current); err != nil {
|
||||
return ImportReport{}, fmt.Errorf("import: save: %w", err)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func collectExportFiles(reg Registry) ([]ExportedFile, error) {
|
||||
type wantFile struct {
|
||||
path string
|
||||
kind string
|
||||
}
|
||||
wants := make([]wantFile, 0)
|
||||
for _, c := range reg.Clusters {
|
||||
if strings.TrimSpace(c.KeyPath) != "" {
|
||||
wants = append(wants, wantFile{path: c.KeyPath, kind: "ssh_private_key"})
|
||||
}
|
||||
if strings.TrimSpace(c.KeyPassphraseFile) != "" {
|
||||
wants = append(wants, wantFile{path: c.KeyPassphraseFile, kind: "ssh_key_passphrase"})
|
||||
}
|
||||
}
|
||||
for _, t := range reg.Backups.Targets {
|
||||
if strings.TrimSpace(t.SFTPKeyPath) != "" {
|
||||
wants = append(wants, wantFile{path: t.SFTPKeyPath, kind: "sftp_private_key"})
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
files := make([]ExportedFile, 0, len(wants))
|
||||
for _, w := range wants {
|
||||
expanded, err := expandPath(w.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: resolve %s file %q: %w", w.kind, w.path, err)
|
||||
}
|
||||
if _, ok := seen[expanded]; ok {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(expanded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: read %s file %q: %w", w.kind, expanded, err)
|
||||
}
|
||||
seen[expanded] = struct{}{}
|
||||
files = append(files, ExportedFile{
|
||||
OriginalPath: expanded,
|
||||
Kind: w.kind,
|
||||
Content: data,
|
||||
})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *Service) restoreExportFiles(files []ExportedFile) (map[string]string, error) {
|
||||
base := filepath.Join(filepath.Dir(s.store.Path()), "imported-files")
|
||||
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("import: create imported-files dir: %w", err)
|
||||
}
|
||||
|
||||
restored := make(map[string]string, len(files))
|
||||
for _, f := range files {
|
||||
orig := strings.TrimSpace(f.OriginalPath)
|
||||
if orig == "" {
|
||||
continue
|
||||
}
|
||||
name := filepath.Base(orig)
|
||||
if name == "." || name == string(filepath.Separator) || strings.TrimSpace(name) == "" {
|
||||
name = "secret"
|
||||
}
|
||||
name = sanitizeExportFilename(name)
|
||||
sum := sha256.Sum256([]byte(orig))
|
||||
outPath := filepath.Join(base, hex.EncodeToString(sum[:6])+"-"+name)
|
||||
if err := os.WriteFile(outPath, f.Content, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("import: restore file %q: %w", orig, err)
|
||||
}
|
||||
restored[orig] = outPath
|
||||
}
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
func applyRestoredFilePaths(reg *Registry, restored map[string]string) {
|
||||
if reg == nil || len(restored) == 0 {
|
||||
return
|
||||
}
|
||||
lookup := func(path string) string {
|
||||
expanded, err := expandPath(path)
|
||||
if err == nil {
|
||||
if restoredPath := strings.TrimSpace(restored[expanded]); restoredPath != "" {
|
||||
return restoredPath
|
||||
}
|
||||
}
|
||||
if restoredPath := strings.TrimSpace(restored[strings.TrimSpace(path)]); restoredPath != "" {
|
||||
return restoredPath
|
||||
}
|
||||
return path
|
||||
}
|
||||
for i := range reg.Clusters {
|
||||
if strings.TrimSpace(reg.Clusters[i].KeyPath) != "" {
|
||||
reg.Clusters[i].KeyPath = lookup(reg.Clusters[i].KeyPath)
|
||||
}
|
||||
if strings.TrimSpace(reg.Clusters[i].KeyPassphraseFile) != "" {
|
||||
reg.Clusters[i].KeyPassphraseFile = lookup(reg.Clusters[i].KeyPassphraseFile)
|
||||
}
|
||||
}
|
||||
for i := range reg.Backups.Targets {
|
||||
if strings.TrimSpace(reg.Backups.Targets[i].SFTPKeyPath) != "" {
|
||||
reg.Backups.Targets[i].SFTPKeyPath = lookup(reg.Backups.Targets[i].SFTPKeyPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeExportFilename(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '_', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), ".")
|
||||
if out == "" {
|
||||
return "secret"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeBackupConfig(current, incoming BackupConfig) BackupConfig {
|
||||
if len(incoming.Targets) == 0 && len(incoming.Plans) == 0 {
|
||||
return current
|
||||
}
|
||||
if current.Targets == nil {
|
||||
current.Targets = []BackupTarget{}
|
||||
}
|
||||
if current.Plans == nil {
|
||||
current.Plans = []BackupPlan{}
|
||||
}
|
||||
|
||||
targetIDMap := map[string]string{}
|
||||
targetByName := map[string]int{}
|
||||
for i, t := range current.Targets {
|
||||
if key := strings.ToLower(strings.TrimSpace(t.Name)); key != "" {
|
||||
targetByName[key] = i
|
||||
}
|
||||
}
|
||||
for _, inc := range incoming.Targets {
|
||||
if strings.TrimSpace(inc.ID) == "" {
|
||||
inc.ID = newClusterID()
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(inc.Name))
|
||||
if key != "" {
|
||||
if idx, ok := targetByName[key]; ok {
|
||||
oldID := current.Targets[idx].ID
|
||||
targetIDMap[inc.ID] = oldID
|
||||
inc.ID = oldID
|
||||
current.Targets[idx] = inc
|
||||
continue
|
||||
}
|
||||
}
|
||||
current.Targets = append(current.Targets, inc)
|
||||
if key != "" {
|
||||
targetByName[key] = len(current.Targets) - 1
|
||||
}
|
||||
}
|
||||
|
||||
planByName := map[string]int{}
|
||||
for i, p := range current.Plans {
|
||||
if key := strings.ToLower(strings.TrimSpace(p.Name)); key != "" {
|
||||
planByName[key] = i
|
||||
}
|
||||
}
|
||||
for _, inc := range incoming.Plans {
|
||||
if mapped := strings.TrimSpace(targetIDMap[inc.TargetID]); mapped != "" {
|
||||
inc.TargetID = mapped
|
||||
}
|
||||
if strings.TrimSpace(inc.ID) == "" {
|
||||
inc.ID = newClusterID()
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(inc.Name))
|
||||
if key != "" {
|
||||
if idx, ok := planByName[key]; ok {
|
||||
inc.ID = current.Plans[idx].ID
|
||||
current.Plans[idx] = inc
|
||||
continue
|
||||
}
|
||||
}
|
||||
current.Plans = append(current.Plans, inc)
|
||||
if key != "" {
|
||||
planByName[key] = len(current.Plans) - 1
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
Reference in New Issue
Block a user