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 }