From c2249059dd68fa77e187da1946b6db6da3969cd5 Mon Sep 17 00:00:00 2001 From: Giuseppe Pagano Date: Wed, 24 Jul 2024 11:41:19 +0200 Subject: [PATCH 1/4] chore: extract backup function from main --- main.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index cdee0e6..dbd9573 100644 --- a/main.go +++ b/main.go @@ -47,10 +47,9 @@ func (c *ChunkState) Init() { c.C.New(1024 * 1024 * 4) } -func main() { - var newchunk atomic.Uint64 - var reusechunk atomic.Uint64 - knownChunks := hashmap.New[string, bool]() +func main2() { + var newchunk *atomic.Uint64 + var reusechunk *atomic.Uint64 // Define command-line flags baseURLFlag := flag.String("baseurl", "", "Base URL for the proxmox backup server, example: https://192.168.1.10:8007") @@ -107,7 +106,18 @@ func main() { }, } - backupdir := *backupSourceDirFlag + backup(client, newchunk, reusechunk, pxarOut, *backupSourceDirFlag) + + fmt.Printf("New %d , Reused %d\n", newchunk.Load(), reusechunk.Load()) + if runtime.GOOS == "windows" { + systray.Quit() + beeep.Notify("Proxmox Backup Go", fmt.Sprintf("Backup complete\nChunks New %d , Reused %d\n", newchunk.Load(), reusechunk.Load()), "") + } + +} + +func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut *string, backupdir string) { + knownChunks := hashmap.New[string, bool]() fmt.Printf("Starting backup of %s\n", backupdir) @@ -316,11 +326,4 @@ func main() { client.UploadManifest() client.Finish() - - fmt.Printf("New %d , Reused %d\n", newchunk.Load(), reusechunk.Load()) - if runtime.GOOS == "windows" { - systray.Quit() - beeep.Notify("Proxmox Backup Go", fmt.Sprintf("Backup complete\nChunks New %d , Reused %d\n", newchunk.Load(), reusechunk.Load()), "") - } - } From 68db2e593614038ff08824092954613b45c5a3a2 Mon Sep 17 00:00:00 2001 From: Giuseppe Pagano Date: Wed, 24 Jul 2024 12:17:46 +0200 Subject: [PATCH 2/4] feat: add support for json configuration --- .gitignore | 1 + config.go | 87 +++++++++++++++++++++++++++++++++++++++++++++ config.json.example | 11 ++++++ main.go | 44 ++++++++--------------- 4 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 config.go create mode 100644 config.json.example diff --git a/.gitignore b/.gitignore index 8efed37..d8cb3da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ proxmoxbackupgo_cli.exe proxmoxbackupgo.exe +config.json \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..9f946ad --- /dev/null +++ b/config.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" +) + +type Config struct { + BaseURL string `json:"baseurl"` + CertFingerprint string `json:"certfingerprint"` + AuthID string `json:"authid"` + Secret string `json:"secret"` + Datastore string `json:"datastore"` + Namespace string `json:"namespace"` + BackupID string `json:"backup-id"` + BackupSourceDir string `json:"backupdir"` + PxarOut string `json:"pxarout"` +} + +func (c *Config) valid() bool { + return c.BaseURL != "" && c.CertFingerprint != "" && c.AuthID != "" && c.Secret != "" && c.Datastore != "" && c.BackupSourceDir != "" +} + +func loadConfig() *Config { + // Define flags + baseURLFlag := flag.String("baseurl", "", "Base URL for the proxmox backup server, example: https://192.168.1.10:8007") + certFingerprintFlag := flag.String("certfingerprint", "", "Certificate fingerprint for SSL connection, example: ea:7d:06:f9...") + authIDFlag := flag.String("authid", "", "Authentication ID (PBS Api token)") + secretFlag := flag.String("secret", "", "Secret for authentication") + datastoreFlag := flag.String("datastore", "", "Datastore name") + namespaceFlag := flag.String("namespace", "", "Namespace (optional)") + backupIDFlag := flag.String("backup-id", "", "Backup ID (optional - if not specified, the hostname is used as the default)") + backupSourceDirFlag := flag.String("backupdir", "", "Backup source directory, must not be symlink") + pxarOutFlag := flag.String("pxarout", "", "Output PXAR archive for debug purposes (optional)") + configFile := flag.String("config", "", "Path to JSON config file") + + // Parse command line flags + flag.Parse() + + // Create a config struct and try to load values from the JSON file if specified + config := &Config{} + if *configFile != "" { + file, err := os.ReadFile(*configFile) + if err != nil { + fmt.Printf("Error reading config file: %v\n", err) + os.Exit(1) + } + err = json.Unmarshal(file, config) + if err != nil { + fmt.Printf("Error parsing config file: %v\n", err) + os.Exit(1) + } + } + + // Override JSON config with command line flags if provided + if *baseURLFlag != "" { + config.BaseURL = *baseURLFlag + } + if *certFingerprintFlag != "" { + config.CertFingerprint = *certFingerprintFlag + } + if *authIDFlag != "" { + config.AuthID = *authIDFlag + } + if *secretFlag != "" { + config.Secret = *secretFlag + } + if *datastoreFlag != "" { + config.Datastore = *datastoreFlag + } + if *namespaceFlag != "" { + config.Namespace = *namespaceFlag + } + if *backupIDFlag != "" { + config.BackupID = *backupIDFlag + } + if *backupSourceDirFlag != "" { + config.BackupSourceDir = *backupSourceDirFlag + } + if *pxarOutFlag != "" { + config.PxarOut = *pxarOutFlag + } + + return config +} diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..fb1b3d6 --- /dev/null +++ b/config.json.example @@ -0,0 +1,11 @@ +{ + "baseurl": "https://your.pbs.installation.net:8007", + "certfingerprint": "XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX", + "authid": "MY-SECRET-AUTH-ID", + "secret": "secret-uuid", + "datastore": "myDatastore", + "backupdir": "C:", + "namespace": "", + "backup-id": "", + "pxarout": "" +} diff --git a/main.go b/main.go index dbd9573..d8f6057 100644 --- a/main.go +++ b/main.go @@ -47,27 +47,13 @@ func (c *ChunkState) Init() { c.C.New(1024 * 1024 * 4) } -func main2() { +func main() { var newchunk *atomic.Uint64 var reusechunk *atomic.Uint64 - // Define command-line flags - baseURLFlag := flag.String("baseurl", "", "Base URL for the proxmox backup server, example: https://192.168.1.10:8007") - certFingerprintFlag := flag.String("certfingerprint", "", "Certificate fingerprint for SSL connection, example: ea:7d:06:f9...") - authIDFlag := flag.String("authid", "", "Authentication ID (PBS Api token)") - secretFlag := flag.String("secret", "", "Secret for authentication") - datastoreFlag := flag.String("datastore", "", "Datastore name") - namespaceFlag := flag.String("namespace", "", "Namespace (optional)") - backupIDFlag := flag.String("backup-id", "", "Backup ID (optional - if not specified, the hostname is used as the default)") - backupSourceDirFlag := flag.String("backupdir", "", "Backup source directory, must not be symlink") - pxarOut := flag.String("pxarout", "", "Output PXAR archive for debug purposes (optional)") - - // Parse command-line flags - flag.Parse() - - // Validate required flags - if *baseURLFlag == "" || *certFingerprintFlag == "" || *authIDFlag == "" || *secretFlag == "" || *datastoreFlag == "" || *backupSourceDirFlag == "" { + cfg := loadConfig() + if ok := cfg.valid(); !ok { if runtime.GOOS == "windows" { usage := "All options are mandatory:\n" flag.VisitAll(func(f *flag.Flag) { @@ -95,18 +81,18 @@ func main2() { } client := &PBSClient{ - baseurl: *baseURLFlag, - certfingerprint: *certFingerprintFlag, //"ea:7d:06:f9:87:73:a4:72:d0:e8:05:a4:b3:3d:95:d7:0a:26:dd:6d:5c:ca:e6:99:83:e4:11:3b:5f:10:f4:4b", - authid: *authIDFlag, - secret: *secretFlag, - datastore: *datastoreFlag, - namespace: *namespaceFlag, + baseurl: cfg.BaseURL, + certfingerprint: cfg.CertFingerprint, //"ea:7d:06:f9:87:73:a4:72:d0:e8:05:a4:b3:3d:95:d7:0a:26:dd:6d:5c:ca:e6:99:83:e4:11:3b:5f:10:f4:4b", + authid: cfg.AuthID, + secret: cfg.Secret, + datastore: cfg.Datastore, + namespace: cfg.Namespace, manifest: BackupManifest{ - BackupID: *backupIDFlag, + BackupID: cfg.BackupID, }, } - backup(client, newchunk, reusechunk, pxarOut, *backupSourceDirFlag) + backup(client, newchunk, reusechunk, cfg.PxarOut, cfg.BackupSourceDir) fmt.Printf("New %d , Reused %d\n", newchunk.Load(), reusechunk.Load()) if runtime.GOOS == "windows" { @@ -116,7 +102,7 @@ func main2() { } -func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut *string, backupdir string) { +func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut string, backupdir string) { knownChunks := hashmap.New[string, bool]() fmt.Printf("Starting backup of %s\n", backupdir) @@ -163,8 +149,8 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut *str fmt.Printf("Known chunks: %d!\n", knownChunks.Len()) f := &os.File{} - if *pxarOut != "" { - f, _ = os.Create(*pxarOut) + if pxarOut != "" { + f, _ = os.Create(pxarOut) defer f.Close() } /**/ @@ -215,7 +201,7 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut *str chunkpos = pxarChunk.C.Scan(b[chunkpos:]) } - if *pxarOut != "" { + if pxarOut != "" { f.Write(b) } // From ace6224fa8311850392be23e06f25fb80be8950b Mon Sep 17 00:00:00 2001 From: Giuseppe Pagano Date: Wed, 24 Jul 2024 13:17:25 +0200 Subject: [PATCH 3/4] fix: better error handling During backup there are multiple operations that may fail which were ignored. This commit begins to address this issue by forwarding the errors back to the main function that can tell the user if the backup was successful or not. Since `panic()` stops the execution of the program, by returning the error to upper levels we can enable more sophisticated notification modes without worring updating every single function that may fail. --- main.go | 56 +++++++++++++++++++++++------ pbsapi.go | 106 ++++++++++++++++++++++++++++-------------------------- 2 files changed, 101 insertions(+), 61 deletions(-) diff --git a/main.go b/main.go index d8f6057..bfbed8e 100644 --- a/main.go +++ b/main.go @@ -92,17 +92,23 @@ func main() { }, } - backup(client, newchunk, reusechunk, cfg.PxarOut, cfg.BackupSourceDir) + err := backup(client, newchunk, reusechunk, cfg.PxarOut, cfg.BackupSourceDir) fmt.Printf("New %d , Reused %d\n", newchunk.Load(), reusechunk.Load()) + var msg string + if err == nil { + msg = fmt.Sprintf("Backup complete\nChunks New %d , Reused %d\n", newchunk.Load(), reusechunk.Load()) + } else { + msg = fmt.Sprintf("Error occurred while working, backup may be not completed.\nLast error is: %s\n", err.Error()) + } if runtime.GOOS == "windows" { systray.Quit() - beeep.Notify("Proxmox Backup Go", fmt.Sprintf("Backup complete\nChunks New %d , Reused %d\n", newchunk.Load(), reusechunk.Load()), "") + beeep.Notify("Proxmox Backup Go", msg, "") } } -func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut string, backupdir string) { +func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut string, backupdir string) error { knownChunks := hashmap.New[string, bool]() fmt.Printf("Starting backup of %s\n", backupdir) @@ -116,7 +122,10 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut stri archive := &PXARArchive{} archive.archivename = "backup.pxar.didx" - previousDidx := client.DownloadPreviousToBytes(archive.archivename) + previousDidx, err := client.DownloadPreviousToBytes(archive.archivename) + if err != nil { + return err + } fmt.Printf("Downloaded previous DIDX: %d bytes\n", len(previousDidx)) @@ -150,7 +159,10 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut stri fmt.Printf("Known chunks: %d!\n", knownChunks.Len()) f := &os.File{} if pxarOut != "" { - f, _ = os.Create(pxarOut) + f, err = os.Create(pxarOut) + if err != nil { + return err + } defer f.Close() } /**/ @@ -161,8 +173,14 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut stri pcat1Chunk := ChunkState{} pcat1Chunk.Init() - pxarChunk.wrid = client.CreateDynamicIndex(archive.archivename) - pcat1Chunk.wrid = client.CreateDynamicIndex("catalog.pcat1.didx") + pxarChunk.wrid, err = client.CreateDynamicIndex(archive.archivename) + if err != nil { + return err + } + pcat1Chunk.wrid, err = client.CreateDynamicIndex("catalog.pcat1.didx") + if err != nil { + return err + } archive.writeCB = func(b []byte) { chunkpos := pxarChunk.C.Scan(b) @@ -175,6 +193,7 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut stri pxarChunk.current_chunk = append(pxarChunk.current_chunk, b[:chunkpos]...) h := sha256.New() + // TODO: error handling inside callback h.Write(pxarChunk.current_chunk) bindigest := h.Sum(nil) shahash := hex.EncodeToString(bindigest) @@ -189,7 +208,9 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut stri reusechunk.Add(1) } + // TODO: error handling inside callback binary.Write(pxarChunk.chunkdigests, binary.LittleEndian, (pxarChunk.pos + uint64(len(pxarChunk.current_chunk)))) + // TODO: error handling inside callback pxarChunk.chunkdigests.Write(h.Sum(nil)) pxarChunk.assignments_offset = append(pxarChunk.assignments_offset, pxarChunk.pos) @@ -202,6 +223,7 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut stri } if pxarOut != "" { + // TODO: error handling inside callback f.Write(b) } // @@ -254,7 +276,11 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut stri if len(pxarChunk.current_chunk) > 0 { h := sha256.New() - h.Write(pxarChunk.current_chunk) + _, err = h.Write(pxarChunk.current_chunk) + if err != nil { + return err + } + shahash := hex.EncodeToString(h.Sum(nil)) binary.Write(pxarChunk.chunkdigests, binary.LittleEndian, (pxarChunk.pos + uint64(len(pxarChunk.current_chunk)))) pxarChunk.chunkdigests.Write(h.Sum(nil)) @@ -276,7 +302,11 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut stri if len(pcat1Chunk.current_chunk) > 0 { h := sha256.New() - h.Write(pcat1Chunk.current_chunk) + _, err = h.Write(pcat1Chunk.current_chunk) + if err != nil { + return err + } + shahash := hex.EncodeToString(h.Sum(nil)) binary.Write(pcat1Chunk.chunkdigests, binary.LittleEndian, (pcat1Chunk.pos + uint64(len(pcat1Chunk.current_chunk)))) pcat1Chunk.chunkdigests.Write(h.Sum(nil)) @@ -310,6 +340,10 @@ func backup(client *PBSClient, newchunk, reusechunk *atomic.Uint64, pxarOut stri client.CloseDynamicIndex(pcat1Chunk.wrid, hex.EncodeToString(pcat1Chunk.chunkdigests.Sum(nil)), pcat1Chunk.pos, pcat1Chunk.chunkcount) - client.UploadManifest() - client.Finish() + err = client.UploadManifest() + if err != nil { + return err + } + + return client.Finish() } diff --git a/pbsapi.go b/pbsapi.go index 32be883..49d9523 100644 --- a/pbsapi.go +++ b/pbsapi.go @@ -94,11 +94,11 @@ type PBSClient struct { var blobCompressedMagic = []byte{49, 185, 88, 66, 111, 182, 163, 127} var blobUncompressedMagic = []byte{66, 171, 56, 7, 190, 131, 112, 161} -func (pbs *PBSClient) CreateDynamicIndex(name string) uint64 { +func (pbs *PBSClient) CreateDynamicIndex(name string) (uint64, error) { req, err := http.NewRequest("POST", pbs.baseurl+"/dynamic_index", bytes.NewBuffer([]byte(fmt.Sprintf("{\"archive-name\": \"%s\"}", name)))) if err != nil { - panic(err) + return 0, err } req.Header.Add("Authorization", fmt.Sprintf("PBSAPIToken=%s:%s", pbs.authid, pbs.secret)) @@ -107,15 +107,13 @@ func (pbs *PBSClient) CreateDynamicIndex(name string) uint64 { resp2, err := pbs.client.Do(req) if err != nil { fmt.Println("Error making request:", err) - panic(err) - return 0 + return 0, err } if resp2.StatusCode != http.StatusOK { resp1, err := io.ReadAll(resp2.Body) fmt.Println("Error making request:", string(resp1), string(resp2.Proto)) - panic(err) - return 0 + return 0, err } resp1, err := io.ReadAll(resp2.Body) @@ -123,7 +121,7 @@ func (pbs *PBSClient) CreateDynamicIndex(name string) uint64 { err = json.Unmarshal(resp1, &R) if err != nil { fmt.Println("Error parsing JSON:", err) - return 0 + return 0, err } fmt.Println("Writer id: ", R.WriterID) defer resp2.Body.Close() @@ -135,9 +133,10 @@ func (pbs *PBSClient) CreateDynamicIndex(name string) uint64 { } pbs.manifest.Files = append(pbs.manifest.Files, f) pbs.writersManifest[uint64(R.WriterID)] = len(pbs.manifest.Files) - 1 - return uint64(R.WriterID) + return uint64(R.WriterID), nil } -func (pbs *PBSClient) UploadUncompressedChunk(writerid uint64, digest string, chunkdata []byte) { + +func (pbs *PBSClient) UploadUncompressedChunk(writerid uint64, digest string, chunkdata []byte) error { outBuffer := make([]byte, 0) outBuffer = append(outBuffer, blobUncompressedMagic...) checksum := crc32.Checksum(chunkdata, crc32.IEEETable) @@ -151,22 +150,25 @@ func (pbs *PBSClient) UploadUncompressedChunk(writerid uint64, digest string, ch q.Add("wid", fmt.Sprintf("%d", writerid)) req, err := http.NewRequest("POST", pbs.baseurl+"/dynamic_chunk?"+q.Encode(), bytes.NewBuffer(outBuffer)) + if err != nil { + return err + } resp2, err := pbs.client.Do(req) if err != nil { fmt.Println("Error making request:", err) - panic(err) - return + return err } if resp2.StatusCode != http.StatusOK { resp1, err := io.ReadAll(resp2.Body) fmt.Println("Error making request:", string(resp1), string(resp2.Proto)) - panic(err) - return + return err } + return nil } -func (pbs *PBSClient) UploadCompressedChunk(writerid uint64, digest string, chunkdata []byte) { + +func (pbs *PBSClient) UploadCompressedChunk(writerid uint64, digest string, chunkdata []byte) error { outBuffer := make([]byte, 0) outBuffer = append(outBuffer, blobCompressedMagic...) compressedData := make([]byte, 0) @@ -184,7 +186,7 @@ func (pbs *PBSClient) UploadCompressedChunk(writerid uint64, digest string, chun if len(compressedData) > len(chunkdata) { pbs.UploadUncompressedChunk(writerid, digest, chunkdata) - return + return nil } //fmt.Printf("Compressed: %d , Orig: %d\n", len(compressedData), len(chunkdata)) @@ -199,52 +201,58 @@ func (pbs *PBSClient) UploadCompressedChunk(writerid uint64, digest string, chun resp2, err := pbs.client.Do(req) if err != nil { fmt.Println("Error making request:", err) - panic(err) - return + return err } if resp2.StatusCode != http.StatusOK { resp1, err := io.ReadAll(resp2.Body) fmt.Println("Error making request:", string(resp1), string(resp2.Proto)) - panic(err) - return + return err } + + return nil } -func (pbs *PBSClient) AssignChunks(writerid uint64, digests []string, offsets []uint64) { +func (pbs *PBSClient) AssignChunks(writerid uint64, digests []string, offsets []uint64) error { indexput := &IndexPutReq{ WriterID: writerid, DigestList: digests, OffsetList: offsets, } - jsondata, _ := json.Marshal(indexput) + jsondata, err := json.Marshal(indexput) + if err != nil { + return err + } req, err := http.NewRequest("PUT", pbs.baseurl+"/dynamic_index", bytes.NewBuffer(jsondata)) if err != nil { - panic(err) + return err } req.Header.Set("Content-Type", "application/json; charset=UTF-8") resp2, err := pbs.client.Do(req) if err != nil { fmt.Println("Error making request:", err) - panic(err) - return + return err } defer resp2.Body.Close() + return nil } -func (pbs *PBSClient) CloseDynamicIndex(writerid uint64, checksum string, totalsize uint64, chunkcount uint64) { +func (pbs *PBSClient) CloseDynamicIndex(writerid uint64, checksum string, totalsize uint64, chunkcount uint64) error { finishreq := &DynamicCloseReq{ WriterID: writerid, CheckSum: checksum, Size: totalsize, ChunkCount: chunkcount, } - jsonpayload, _ := json.Marshal(finishreq) + jsonpayload, err := json.Marshal(finishreq) + if err != nil { + return err + } req, err := http.NewRequest("POST", pbs.baseurl+"/dynamic_close", bytes.NewBuffer(jsonpayload)) if err != nil { - panic(err) + return err } req.Header.Add("Authorization", fmt.Sprintf("PBSAPIToken=%s:%s", pbs.authid, pbs.secret)) req.Header.Set("Content-Type", "application/json; charset=UTF-8") @@ -252,8 +260,7 @@ func (pbs *PBSClient) CloseDynamicIndex(writerid uint64, checksum string, totals resp2, err := pbs.client.Do(req) if err != nil { fmt.Println("Error making request:", err) - panic(err) - return + return err } f := &pbs.manifest.Files[pbs.writersManifest[writerid]] @@ -262,9 +269,10 @@ func (pbs *PBSClient) CloseDynamicIndex(writerid uint64, checksum string, totals f.Size = int64(totalsize) defer resp2.Body.Close() + return nil } -func (pbs *PBSClient) UploadBlob(name string, data []byte) { +func (pbs *PBSClient) UploadBlob(name string, data []byte) error { out := make([]byte, 0) out = append(out, blobUncompressedMagic...) @@ -281,39 +289,41 @@ func (pbs *PBSClient) UploadBlob(name string, data []byte) { resp2, err := pbs.client.Do(req) if err != nil { fmt.Println("Error making request:", err) - panic(err) - return + return err } if resp2.StatusCode != http.StatusOK { resp1, err := io.ReadAll(resp2.Body) fmt.Println("Error making request:", string(resp1), string(resp2.Proto)) - panic(err) - return + return err } + return nil } -func (pbs *PBSClient) UploadManifest() { - manifestBin, _ := json.Marshal(pbs.manifest) - pbs.UploadBlob("index.json.blob", manifestBin) +func (pbs *PBSClient) UploadManifest() error { + manifestBin, err := json.Marshal(pbs.manifest) + if err != nil { + return err + } + return pbs.UploadBlob("index.json.blob", manifestBin) } -func (pbs *PBSClient) Finish() { +func (pbs *PBSClient) Finish() error { req, err := http.NewRequest("POST", pbs.baseurl+"/finish", nil) req.Header.Add("Authorization", fmt.Sprintf("PBSAPIToken=%s:%s", pbs.authid, pbs.secret)) if err != nil { - panic(err) + return err } resp2, err := pbs.client.Do(req) if err != nil { fmt.Println("Error making request:", err) if err != nil { - panic(err) + return err } - return } defer resp2.Body.Close() + return nil } func (pbs *PBSClient) Connect(reader bool) { @@ -416,8 +426,7 @@ func (pbs *PBSClient) Connect(reader bool) { } -func (pbs *PBSClient) DownloadPreviousToBytes(archivename string) []byte { //In the future also download to tmp if index is extremely big... - +func (pbs *PBSClient) DownloadPreviousToBytes(archivename string) ([]byte, error) { //In the future also download to tmp if index is extremely big... q := &url.Values{} q.Add("archive-name", archivename) @@ -425,24 +434,21 @@ func (pbs *PBSClient) DownloadPreviousToBytes(archivename string) []byte { //In req, err := http.NewRequest("GET", pbs.baseurl+"/previous?"+q.Encode(), nil) req.Header.Add("Authorization", fmt.Sprintf("PBSAPIToken=%s:%s", pbs.authid, pbs.secret)) if err != nil { - panic(err) + return nil, err } resp2, err := pbs.client.Do(req) if err != nil { fmt.Println("Error making request:", err) - if err != nil { - panic(err) - } - return make([]byte, 0) + return nil, err } defer resp2.Body.Close() ret, err := io.ReadAll(resp2.Body) if err != nil { - panic(err) + return nil, err } - return ret + return ret, nil } From df850558314c4e1ee30f670102374ee1f6ae56b9 Mon Sep 17 00:00:00 2001 From: Giuseppe Pagano Date: Wed, 24 Jul 2024 14:17:02 +0200 Subject: [PATCH 4/4] feat: add the capability to send mail notifications --- config.go | 125 +++++++++++++++++++++++++++++++++----------- config.json.example | 16 +++++- mail.go | 115 ++++++++++++++++++++++++++++++++++++++++ main.go | 23 +++++++- 4 files changed, 246 insertions(+), 33 deletions(-) create mode 100644 mail.go diff --git a/config.go b/config.go index 9f946ad..f5ba763 100644 --- a/config.go +++ b/config.go @@ -7,20 +7,51 @@ import ( "os" ) +type MailSendConfig struct { + From string `json:"from"` + To string `json:"to"` +} + +type SMTPConfig struct { + Host string `json:"host"` + Port string `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Insecure bool `json:"insecure"` + Mails []MailSendConfig `json:"mails"` +} + type Config struct { - BaseURL string `json:"baseurl"` - CertFingerprint string `json:"certfingerprint"` - AuthID string `json:"authid"` - Secret string `json:"secret"` - Datastore string `json:"datastore"` - Namespace string `json:"namespace"` - BackupID string `json:"backup-id"` - BackupSourceDir string `json:"backupdir"` - PxarOut string `json:"pxarout"` + BaseURL string `json:"baseurl"` + CertFingerprint string `json:"certfingerprint"` + AuthID string `json:"authid"` + Secret string `json:"secret"` + Datastore string `json:"datastore"` + Namespace string `json:"namespace"` + BackupID string `json:"backup-id"` + BackupSourceDir string `json:"backupdir"` + PxarOut string `json:"pxarout"` + SMTP *SMTPConfig `json:"smtp"` } func (c *Config) valid() bool { - return c.BaseURL != "" && c.CertFingerprint != "" && c.AuthID != "" && c.Secret != "" && c.Datastore != "" && c.BackupSourceDir != "" + baseValid := c.BaseURL != "" && c.CertFingerprint != "" && c.AuthID != "" && c.Secret != "" && c.Datastore != "" && c.BackupSourceDir != "" + if !baseValid { + return baseValid + } + + if c.SMTP != nil { + mailCfgValid := c.SMTP.Host != "" && c.SMTP.Port != "" && c.SMTP.Username != "" && c.SMTP.Password != "" + if len(c.SMTP.Mails) == 0 { + return false + } + for i := range c.SMTP.Mails { + mailCfgValid = mailCfgValid && (c.SMTP.Mails[i].From != "" && c.SMTP.Mails[i].To != "") + } + return mailCfgValid + } + + return true } func loadConfig() *Config { @@ -34,12 +65,20 @@ func loadConfig() *Config { backupIDFlag := flag.String("backup-id", "", "Backup ID (optional - if not specified, the hostname is used as the default)") backupSourceDirFlag := flag.String("backupdir", "", "Backup source directory, must not be symlink") pxarOutFlag := flag.String("pxarout", "", "Output PXAR archive for debug purposes (optional)") - configFile := flag.String("config", "", "Path to JSON config file") + + mailHostFlag := flag.String("mail-host", "", "mail notification system: mail server host(optional)") + mailPortFlag := flag.String("mail-port", "", "mail notification system: mail server port(optional)") + mailUsernameFlag := flag.String("mail-username", "", "mail notification system: mail server username(optional)") + mailPasswordFlag := flag.String("mail-password", "", "mail notification system: mail server password(optional)") + mailInsecureFlag := flag.Bool("mail-insecure", false, "mail notification system: allow insecure communications(optional)") + mailFromFlag := flag.String("mail-from", "", "mail notification system: sender mail(optional)") + mailToFlag := flag.String("mail-to", "", "mail notification system: receiver mail(optional)") + + configFile := flag.String("config", "", "Path to JSON config file. If this flag is provided all the others are ignored") // Parse command line flags flag.Parse() - // Create a config struct and try to load values from the JSON file if specified config := &Config{} if *configFile != "" { file, err := os.ReadFile(*configFile) @@ -52,35 +91,59 @@ func loadConfig() *Config { fmt.Printf("Error parsing config file: %v\n", err) os.Exit(1) } + + return config } - // Override JSON config with command line flags if provided - if *baseURLFlag != "" { - config.BaseURL = *baseURLFlag + config.BaseURL = *baseURLFlag + config.CertFingerprint = *certFingerprintFlag + config.AuthID = *authIDFlag + config.Secret = *secretFlag + config.Datastore = *datastoreFlag + config.Namespace = *namespaceFlag + config.BackupID = *backupIDFlag + config.BackupSourceDir = *backupSourceDirFlag + config.PxarOut = *pxarOutFlag + + initSmtpConfigIfNeeded := func() { + if config.SMTP == nil { + config.SMTP = &SMTPConfig{} + } } - if *certFingerprintFlag != "" { - config.CertFingerprint = *certFingerprintFlag + initMailConfsIfNeeded := func() { + initSmtpConfigIfNeeded() + if len(config.SMTP.Mails) == 0 { + config.SMTP.Mails = append(config.SMTP.Mails, MailSendConfig{}) + } } - if *authIDFlag != "" { - config.AuthID = *authIDFlag + + if *mailHostFlag != "" { + initSmtpConfigIfNeeded() + config.SMTP.Host = *mailHostFlag } - if *secretFlag != "" { - config.Secret = *secretFlag + if *mailPortFlag != "" { + initSmtpConfigIfNeeded() + config.SMTP.Port = *mailPortFlag } - if *datastoreFlag != "" { - config.Datastore = *datastoreFlag + if *mailUsernameFlag != "" { + initSmtpConfigIfNeeded() + config.SMTP.Username = *mailUsernameFlag } - if *namespaceFlag != "" { - config.Namespace = *namespaceFlag + if *mailPasswordFlag != "" { + initSmtpConfigIfNeeded() + config.SMTP.Password = *mailPasswordFlag } - if *backupIDFlag != "" { - config.BackupID = *backupIDFlag + if *mailInsecureFlag { + initSmtpConfigIfNeeded() + config.SMTP.Insecure = *mailInsecureFlag } - if *backupSourceDirFlag != "" { - config.BackupSourceDir = *backupSourceDirFlag + if *mailFromFlag != "" { + initMailConfsIfNeeded() + config.SMTP.Mails[0].From = *mailFromFlag } - if *pxarOutFlag != "" { - config.PxarOut = *pxarOutFlag + if *mailToFlag != "" { + initMailConfsIfNeeded() + config.SMTP.Mails[0].To = *mailToFlag } return config diff --git a/config.json.example b/config.json.example index fb1b3d6..7250583 100644 --- a/config.json.example +++ b/config.json.example @@ -7,5 +7,19 @@ "backupdir": "C:", "namespace": "", "backup-id": "", - "pxarout": "" + "pxarout": "", + "smtp": { + "host": "smtp.example.com", + "port": "465", + "username": "my-user@example.com", + "password": "my-password", + "mails": [{ + "from": "sender1@example.com", + "to": "receiver1@example.com + }, { + "from": "sender2@example.com", + "to": "receiver2@example.com + }] + + } } diff --git a/mail.go b/mail.go new file mode 100644 index 0000000..5a599d6 --- /dev/null +++ b/mail.go @@ -0,0 +1,115 @@ +package main + +import ( + "crypto/tls" + "errors" + "fmt" + "net/smtp" +) + +type unencryptedAuth struct { + smtp.Auth +} + +func (a unencryptedAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + s := *server + s.TLS = true + return a.Auth.Start(&s) +} + +func setupClient(host, port, username, password string, allowInsecure bool) (*smtp.Client, error) { + var auth smtp.Auth + auth = smtp.PlainAuth("", username, password, host) + if port == "25" { + if !allowInsecure { + return nil, errors.New("sending plain password over unencrypted connection") + } + auth = unencryptedAuth{auth} + } + + var tlsconfig *tls.Config + if port != "25" { + // TLS config + tlsconfig = &tls.Config{ + InsecureSkipVerify: allowInsecure, + ServerName: host, + } + } + + servername := host + ":" + port + + var c *smtp.Client + var err error + if port == "465" { + // Here is the key, you need to call tls.Dial instead of smtp.Dial + // for smtp servers running on 465 that require an ssl connection + // from the very beginning (no starttls) + conn, err := tls.Dial("tcp", servername, tlsconfig) + if err != nil { + return nil, err + } + + c, err = smtp.NewClient(conn, host) + if err != nil { + return nil, err + } + } else { + c, err = smtp.Dial(servername) + if err != nil { + return nil, err + } + if port == "587" { + c.StartTLS(tlsconfig) + } + } + + // Auth + if err = c.Auth(auth); err != nil { + fmt.Println("here", err) + return nil, err + } + + return c, nil +} + +func sendMail(from, to, subject, body string, c *smtp.Client) error { + // Setup headers + headers := make(map[string]string) + headers["From"] = from + headers["To"] = to + headers["Subject"] = subject + + // Setup message + message := "" + for k, v := range headers { + message += fmt.Sprintf("%s: %s\r\n", k, v) + } + message += "\r\n" + body + + // To && From + if err := c.Mail(from); err != nil { + return err + } + + if err := c.Rcpt(to); err != nil { + return err + } + + // Data + w, err := c.Data() + if err != nil { + return err + } + + _, err = w.Write([]byte(message)) + if err != nil { + return err + } + + err = w.Close() + if err != nil { + return err + } + + return nil +} diff --git a/main.go b/main.go index bfbed8e..88e5b4e 100644 --- a/main.go +++ b/main.go @@ -73,7 +73,7 @@ func main() { go systray.Run(func() { systray.SetIcon(ICON) systray.SetTooltip("PBSGO Backup running") - beeep.Notify("Proxmox Backup Go", fmt.Sprintf("Backup started"), "") + beeep.Notify("Proxmox Backup Go", "Backup started", "") }, func() { @@ -105,6 +105,27 @@ func main() { systray.Quit() beeep.Notify("Proxmox Backup Go", msg, "") } + if cfg.SMTP != nil { + var subject string + if err == nil { + subject = "Backup complete" + } else { + subject = "Backup error" + } + client, err := setupClient(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.Insecure) + if err != nil { + fmt.Println("Cannot connect to mail server: " + err.Error()) + os.Exit(1) + } + defer client.Quit() + for _, ccc := range cfg.SMTP.Mails { + err = sendMail(ccc.From, ccc.To, subject, msg, client) + if err != nil { + fmt.Println("Cannot send email: " + err.Error()) + os.Exit(1) + } + } + } }