chore: publish pxmon v0.2.0

This commit is contained in:
2026-06-16 21:52:10 +04:00
commit 6b703db02b
69 changed files with 25886 additions and 0 deletions
+456
View File
@@ -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
}